Compare commits
5 Commits
master
...
apk-fix-bu
| Author | SHA1 | Date |
|---|---|---|
|
|
eb4cd74f02 | |
|
|
1820739a49 | |
|
|
265ee83e03 | |
|
|
07bd567cdb | |
|
|
1d702102fc |
|
|
@ -5,15 +5,18 @@ import '../../../core/theme/app_typography.dart';
|
|||
import '../../../services/alerts/road_alert_models.dart';
|
||||
import '../../../services/alerts/road_alert_service.dart';
|
||||
import '../../../services/map/map_provider_interface.dart';
|
||||
import '../../../services/map/map_providers.dart';
|
||||
import '../../../services/map/unified_map_view.dart';
|
||||
import '../../../services/navigation/navigation_session_service.dart';
|
||||
import '../../../services/telemetry_service.dart';
|
||||
import '../../rides/ride_state_machine.dart';
|
||||
|
||||
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
|
||||
///
|
||||
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
|
||||
/// and alert pins via [activeAlertsStreamProvider].
|
||||
class DashboardMapPanel extends ConsumerWidget {
|
||||
/// and alert pins via [activeAlertsStreamProvider], maintains camera follow
|
||||
/// state, and provides a recenter control after manual pan.
|
||||
class DashboardMapPanel extends ConsumerStatefulWidget {
|
||||
final double zoom;
|
||||
|
||||
const DashboardMapPanel({
|
||||
|
|
@ -22,7 +25,43 @@ class DashboardMapPanel extends ConsumerWidget {
|
|||
});
|
||||
|
||||
@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(
|
||||
telemetryStreamProvider.select(
|
||||
(s) => (
|
||||
|
|
@ -34,19 +73,18 @@ class DashboardMapPanel extends ConsumerWidget {
|
|||
),
|
||||
);
|
||||
|
||||
final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0)
|
||||
? vehicleCoords.lat!
|
||||
: 37.7749;
|
||||
final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0)
|
||||
? vehicleCoords.lng!
|
||||
: -122.4194;
|
||||
final hasCoordinates = _isValidCoord(vehicleCoords.lat, vehicleCoords.lng);
|
||||
if (hasCoordinates) {
|
||||
_lastKnownRiderPos = AppLatLng(vehicleCoords.lat!, vehicleCoords.lng!);
|
||||
}
|
||||
|
||||
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
|
||||
final isFresh = vehicleCoords.isGpsFresh;
|
||||
final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0;
|
||||
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||
|
||||
final vehicleMarker = AppMapMarker(
|
||||
id: 'dash_vehicle',
|
||||
position: AppLatLng(centerLat, centerLng),
|
||||
position: activeCenter,
|
||||
heading: vehicleCoords.heading,
|
||||
iconWidget: Icon(
|
||||
Icons.navigation,
|
||||
|
|
@ -64,7 +102,7 @@ class DashboardMapPanel extends ConsumerWidget {
|
|||
for (final alert in activeAlerts) {
|
||||
mapMarkers.add(
|
||||
AppMapMarker(
|
||||
id: 'dash_alert__',
|
||||
id: 'dash_alert_${alert.id}',
|
||||
position: AppLatLng(alert.latitude, alert.longitude),
|
||||
heading: alert.heading ?? 0.0,
|
||||
label: alert.category.displayName,
|
||||
|
|
@ -116,14 +154,14 @@ class DashboardMapPanel extends ConsumerWidget {
|
|||
Positioned.fill(
|
||||
child: UnifiedMapView(
|
||||
key: const ValueKey('dash_map_x'),
|
||||
center: AppLatLng(centerLat, centerLng),
|
||||
zoom: zoom,
|
||||
center: activeCenter,
|
||||
zoom: widget.zoom,
|
||||
bearing: 0.0,
|
||||
markers: mapMarkers,
|
||||
polylines: polylines,
|
||||
),
|
||||
),
|
||||
if (!isFresh && hasCoordinates)
|
||||
if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null))
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 10,
|
||||
|
|
@ -154,6 +192,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 +250,4 @@ class DashboardMapPanel extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -100,6 +100,14 @@ class _FeedCommentsBottomSheetState
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 feedItems = ref.watch(socialFeedNotifierProvider);
|
||||
final livePost = feedItems.cast<SocialFeedItem?>().firstWhere(
|
||||
|
|
@ -116,14 +124,14 @@ class _FeedCommentsBottomSheetState
|
|||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF101419),
|
||||
color: colors.surfaceCardPrimary,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
||||
boxShadow: const [
|
||||
border: Border.all(color: colors.surfaceBorder),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black87,
|
||||
color: colors.glowShadow,
|
||||
blurRadius: 30,
|
||||
offset: Offset(0, -10),
|
||||
offset: const Offset(0, -10),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -139,7 +147,7 @@ class _FeedCommentsBottomSheetState
|
|||
width: 44,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
color: colors.surfaceBorder,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
|
@ -149,9 +157,9 @@ class _FeedCommentsBottomSheetState
|
|||
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
color: AppColors.electricCyan,
|
||||
color: accentColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -161,7 +169,7 @@ class _FeedCommentsBottomSheetState
|
|||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -169,33 +177,35 @@ class _FeedCommentsBottomSheetState
|
|||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1D2025),
|
||||
color: colors.surfaceCardSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.electricCyan.withValues(alpha: 0.3),
|
||||
color: accentColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${comments.length}',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.electricCyan,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded,
|
||||
color: Color(0xFF849396)),
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
color: secondaryTextColor,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: 'Close',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Color(0xFF1D2025), height: 1),
|
||||
Divider(color: colors.surfaceBorder, height: 1),
|
||||
|
||||
// Comment List or Empty
|
||||
Flexible(
|
||||
|
|
@ -209,16 +219,16 @@ class _FeedCommentsBottomSheetState
|
|||
Icon(
|
||||
Icons.forum_outlined,
|
||||
size: 40,
|
||||
color: colors.outline.withValues(alpha: 0.4),
|
||||
color: secondaryTextColor,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
Text(
|
||||
'No debrief comments yet',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white70,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
|
@ -228,7 +238,7 @@ class _FeedCommentsBottomSheetState
|
|||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: colors.outline,
|
||||
color: secondaryTextColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -246,10 +256,10 @@ class _FeedCommentsBottomSheetState
|
|||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181C21),
|
||||
color: colors.surfaceCardSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF272A30),
|
||||
color: colors.surfaceBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
|
|
@ -259,12 +269,11 @@ class _FeedCommentsBottomSheetState
|
|||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor:
|
||||
const Color(0xFF272A30),
|
||||
child: const Icon(
|
||||
backgroundColor: colors.surfaceCardPrimary,
|
||||
child: Icon(
|
||||
Icons.sports_motorsports_rounded,
|
||||
size: 16,
|
||||
color: AppColors.electricCyan,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -275,12 +284,12 @@ class _FeedCommentsBottomSheetState
|
|||
child: Text(
|
||||
c.authorDisplayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily:
|
||||
AppTypography.fontHeadline,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -292,7 +301,7 @@ class _FeedCommentsBottomSheetState
|
|||
fontFamily:
|
||||
AppTypography.fontBody,
|
||||
fontSize: 11,
|
||||
color: colors.outline,
|
||||
color: secondaryTextColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -304,7 +313,7 @@ class _FeedCommentsBottomSheetState
|
|||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 10,
|
||||
color: colors.outline,
|
||||
color: secondaryTextColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -314,11 +323,11 @@ class _FeedCommentsBottomSheetState
|
|||
padding: const EdgeInsets.only(left: 36),
|
||||
child: Text(
|
||||
c.content,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
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)
|
||||
Padding(
|
||||
|
|
@ -340,28 +349,28 @@ class _FeedCommentsBottomSheetState
|
|||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181C21),
|
||||
color: colors.surfaceCardSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF272A30),
|
||||
color: colors.surfaceBorder,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _commentController,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: Colors.white,
|
||||
color: colors.textPrimary,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Add comment or pace tip...',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
color: Color(0xFF849396),
|
||||
color: secondaryTextColor,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 14,
|
||||
),
|
||||
|
|
@ -376,8 +385,8 @@ class _FeedCommentsBottomSheetState
|
|||
width: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.electricCyan,
|
||||
foregroundColor: const Color(0xFF00363D),
|
||||
backgroundColor: sendBg,
|
||||
foregroundColor: sendFg,
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -387,12 +396,12 @@ class _FeedCommentsBottomSheetState
|
|||
onPressed:
|
||||
_isSubmitting ? null : () => _handlePostComment(),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFF00363D),
|
||||
color: sendFg,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_rounded, size: 20),
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ class _FeedTelemetryInspectorModalState
|
|||
SocialFeedItem item,
|
||||
MotorSignalThemeColors colors,
|
||||
) {
|
||||
final hasRecorded = item.hasRecordedTelemetry;
|
||||
return Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -455,33 +456,90 @@ class _FeedTelemetryInspectorModalState
|
|||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate: (details) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box != null) {
|
||||
final localX = details.localPosition.dx;
|
||||
setState(() {
|
||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
},
|
||||
onTapDown: (details) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box != null) {
|
||||
final localX = details.localPosition.dx;
|
||||
setState(() {
|
||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: CustomPaint(
|
||||
size: const Size(double.infinity, 220),
|
||||
painter: _TelemetryWaveformPainter(
|
||||
tab: _selectedTab,
|
||||
item: item,
|
||||
scrubFraction: _scrubFraction,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onHorizontalDragUpdate: hasRecorded
|
||||
? (details) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box != null) {
|
||||
final localX = details.localPosition.dx;
|
||||
setState(() {
|
||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onTapDown: hasRecorded
|
||||
? (details) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box != null) {
|
||||
final localX = details.localPosition.dx;
|
||||
setState(() {
|
||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: CustomPaint(
|
||||
size: const Size(double.infinity, 220),
|
||||
painter: _TelemetryWaveformPainter(
|
||||
tab: _selectedTab,
|
||||
item: item,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -491,10 +549,11 @@ class _FeedTelemetryInspectorModalState
|
|||
SocialFeedItem item,
|
||||
MotorSignalThemeColors colors,
|
||||
) {
|
||||
final hasRecorded = item.hasRecordedTelemetry;
|
||||
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
|
||||
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);
|
||||
|
||||
return Container(
|
||||
|
|
@ -520,7 +579,9 @@ class _FeedTelemetryInspectorModalState
|
|||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'POINT: $scrubDist MI ($scrubPct%)',
|
||||
hasRecorded
|
||||
? 'POINT: $scrubDist MI ($scrubPct%)'
|
||||
: 'TELEMETRY TRACE UNAVAILABLE',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
|
|
@ -535,9 +596,9 @@ class _FeedTelemetryInspectorModalState
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'DRAG TO SCRUB',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 8.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -550,11 +611,11 @@ class _FeedTelemetryInspectorModalState
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildScrubStat('SPEED', '$speedVal MPH'),
|
||||
_buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH'),
|
||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
||||
_buildScrubStat('LEAN', '$leanVal°'),
|
||||
_buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°'),
|
||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
||||
_buildScrubStat('LAT G', '${gVal}G'),
|
||||
_buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G'),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -563,17 +624,28 @@ class _FeedTelemetryInspectorModalState
|
|||
}
|
||||
|
||||
(int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) {
|
||||
// Generate realistic wave curves based on session metrics
|
||||
final baseSpeed = 48.0;
|
||||
final speedVariation = 26.0 * math.sin(frac * math.pi * 5);
|
||||
final speed = (baseSpeed + speedVariation).clamp(20, 115).toInt();
|
||||
if (!item.hasRecordedTelemetry || item.telemetrySamples.isEmpty) {
|
||||
return (0, 0, '0.00');
|
||||
}
|
||||
|
||||
final leanVariation = item.maxLeanAngleDeg * math.sin(frac * math.pi * 7).abs();
|
||||
final lean = leanVariation.clamp(0, item.maxLeanAngleDeg).toInt();
|
||||
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 gForce = (0.2 + (lean / 60.0) * 0.9).toStringAsFixed(2);
|
||||
final a = samples[lowerIdx];
|
||||
final b = samples[upperIdx];
|
||||
|
||||
return (speed, lean, gForce);
|
||||
final speed = (a.speedMph + (b.speedMph - a.speedMph) * t).round();
|
||||
final lean = (a.leanAngleDeg.abs() +
|
||||
(b.leanAngleDeg.abs() - a.leanAngleDeg.abs()) * 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) {
|
||||
|
|
@ -640,7 +712,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 {
|
||||
final _InspectorTraceTab tab;
|
||||
final SocialFeedItem item;
|
||||
|
|
@ -671,6 +744,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
|
||||
}
|
||||
|
||||
if (!item.hasRecordedTelemetry || item.telemetrySamples.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (tab) {
|
||||
case _InspectorTraceTab.leanApex:
|
||||
_paintLeanWaveform(canvas, size);
|
||||
|
|
@ -701,6 +778,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
final w = size.width;
|
||||
final h = size.height;
|
||||
final midY = h * 0.52;
|
||||
final samples = item.telemetrySamples;
|
||||
|
||||
// Baseline (0° upright)
|
||||
final baselinePaint = Paint()
|
||||
|
|
@ -710,24 +788,27 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
|
||||
|
||||
final path = Path();
|
||||
final points = <Offset>[];
|
||||
const steps = 100;
|
||||
final maxLean = item.maxLeanAngleDeg;
|
||||
var peakIndex = 0;
|
||||
var peakAbsLean = 0.0;
|
||||
var peakPoint = Offset(0, midY);
|
||||
|
||||
for (var i = 0; i <= steps; i++) {
|
||||
final t = i / steps;
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final s = samples[i];
|
||||
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||
final x = t * w;
|
||||
// Multi-sine wave generating cornering sections
|
||||
final wave = math.sin(t * math.pi * 6) * 0.7 +
|
||||
math.sin(t * math.pi * 12) * 0.3;
|
||||
final leanAngle = wave * maxLean;
|
||||
final y = midY - (leanAngle / 60.0) * (h * 0.38);
|
||||
points.add(Offset(x, y));
|
||||
final leanAngle = s.leanAngleDeg.clamp(-65.0, 65.0);
|
||||
final y = midY - (leanAngle / 65.0) * (h * 0.38);
|
||||
final pt = Offset(x, y);
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
if (s.leanAngleDeg.abs() >= peakAbsLean) {
|
||||
peakAbsLean = s.leanAngleDeg.abs();
|
||||
peakIndex = i;
|
||||
peakPoint = pt;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Gradient Area under Lean Curve
|
||||
|
|
@ -754,49 +835,59 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
..style = PaintingStyle.stroke;
|
||||
canvas.drawPath(path, linePaint);
|
||||
|
||||
// Draw Apex Markers (Peak Corners)
|
||||
final apexMarkers = [
|
||||
(0.26, '${item.maxLeanAngleDeg.toStringAsFixed(0)}° APEX', const Color(0xFF00E676)),
|
||||
(0.68, '${(item.maxLeanAngleDeg * 0.94).toStringAsFixed(0)}° SWEEP', const Color(0xFFFFB300)),
|
||||
];
|
||||
|
||||
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);
|
||||
if (peakIndex >= 0 && peakAbsLean > 0) {
|
||||
const markerColor = Color(0xFF00E676);
|
||||
final dotPaint = Paint()..color = markerColor;
|
||||
canvas.drawCircle(peakPoint, 5.0, dotPaint);
|
||||
|
||||
final ringPaint = Paint()
|
||||
..color = marker.$3.withValues(alpha: 0.4)
|
||||
..color = markerColor.withValues(alpha: 0.4)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3;
|
||||
canvas.drawCircle(Offset(mx, my), 8.0, ringPaint);
|
||||
canvas.drawCircle(peakPoint, 8.0, ringPaint);
|
||||
|
||||
// Label Pill
|
||||
_drawTextBadge(canvas, marker.$2, Offset(mx - 24, my - 24), marker.$3);
|
||||
_drawTextBadge(
|
||||
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) {
|
||||
final w = size.width;
|
||||
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();
|
||||
const steps = 100;
|
||||
var peakSpeed = 0.0;
|
||||
var peakPoint = Offset(w * 0.5, h * 0.5);
|
||||
|
||||
for (var i = 0; i <= steps; i++) {
|
||||
final t = i / steps;
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final s = samples[i];
|
||||
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||
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 - (speed / 120.0) * (h * 0.75);
|
||||
final y = (h * 0.90 - (s.speedMph / speedScale) * (h * 0.75))
|
||||
.clamp(h * 0.1, h * 0.90);
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
if (s.speedMph >= peakSpeed) {
|
||||
peakSpeed = s.speedMph;
|
||||
peakPoint = Offset(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
final fillPath = Path.from(path)
|
||||
|
|
@ -821,26 +912,49 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
..style = PaintingStyle.stroke;
|
||||
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) {
|
||||
final w = size.width;
|
||||
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();
|
||||
const steps = 100;
|
||||
var crestPoint = Offset(w * 0.5, h * 0.25);
|
||||
var highestAlt = minAlt;
|
||||
|
||||
for (var i = 0; i <= steps; i++) {
|
||||
final t = i / steps;
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final s = samples[i];
|
||||
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||
final x = t * w;
|
||||
final elevationNorm = 0.2 + 0.65 * (math.sin(t * math.pi * 2.2) * 0.5 + 0.5);
|
||||
final y = h * 0.92 - elevationNorm * (h * 0.75);
|
||||
final norm = altSpan < 1.0 ? 0.5 : (s.altitudeFt - minAlt) / altSpan;
|
||||
final y = h * 0.92 - (0.15 + norm * 0.70) * (h * 0.75);
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
if (s.altitudeFt >= highestAlt) {
|
||||
highestAlt = s.altitudeFt;
|
||||
crestPoint = Offset(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
final fillPath = Path.from(path)
|
||||
|
|
@ -868,7 +982,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
|||
_drawTextBadge(
|
||||
canvas,
|
||||
'+${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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,28 @@ import '../../../garage/garage_providers.dart';
|
|||
/// Modal dialog / bottom sheet allowing riders to share their recent ride
|
||||
/// or canyon session to the Community Social Feed.
|
||||
class ShareSessionModal extends ConsumerStatefulWidget {
|
||||
const ShareSessionModal({super.key});
|
||||
final Ride? initialRide;
|
||||
final List<TelemetrySample>? initialTelemetrySamples;
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
const ShareSessionModal({
|
||||
super.key,
|
||||
this.initialRide,
|
||||
this.initialTelemetrySamples,
|
||||
});
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
Ride? initialRide,
|
||||
List<TelemetrySample>? initialTelemetrySamples,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => const ShareSessionModal(),
|
||||
builder: (ctx) => ShareSessionModal(
|
||||
initialRide: initialRide,
|
||||
initialTelemetrySamples: initialTelemetrySamples,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -33,38 +47,104 @@ class ShareSessionModal extends ConsumerStatefulWidget {
|
|||
class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleController = TextEditingController(text: 'Morning Canyon Carve');
|
||||
final _locationController = TextEditingController(text: 'Skyline Blvd & Highway 9');
|
||||
final _locationController =
|
||||
TextEditingController(text: 'Skyline Blvd & Highway 9');
|
||||
final _bikeController = TextEditingController();
|
||||
final _distanceController = TextEditingController(text: '48.5');
|
||||
final _durationController = TextEditingController(text: '52');
|
||||
final _maxLeanController = TextEditingController(text: '49');
|
||||
String _selectedLeanSide = 'RIGHT';
|
||||
String _selectedPace = '1.25x Spirited';
|
||||
RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
List<Ride> _recentRides = [];
|
||||
Ride? _selectedRide;
|
||||
List<AppLatLng> _selectedRoutePoints = const [
|
||||
AppLatLng(37.3861, -122.2641),
|
||||
AppLatLng(37.3500, -122.2300),
|
||||
AppLatLng(37.3100, -122.2000),
|
||||
AppLatLng(37.2800, -122.2200),
|
||||
];
|
||||
List<AppLatLng> _selectedRoutePoints = const [];
|
||||
List<FeedTelemetrySample> _selectedTelemetrySamples = const [];
|
||||
|
||||
double get _authoritativeDistanceMiles {
|
||||
final ride = _selectedRide;
|
||||
if (ride == null) return 0.0;
|
||||
return ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
|
||||
}
|
||||
|
||||
int get _authoritativeDurationSeconds {
|
||||
final ride = _selectedRide;
|
||||
if (ride == null) return 0;
|
||||
if (ride.movingTime > 0) return ride.movingTime;
|
||||
if (ride.endTime != null) {
|
||||
return ride.endTime!
|
||||
.difference(ride.startTime)
|
||||
.inSeconds
|
||||
.clamp(0, 864000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int get _authoritativeDurationMinutes =>
|
||||
(_authoritativeDurationSeconds / 60).round();
|
||||
|
||||
double get _authoritativeMaxLeanDeg {
|
||||
final ride = _selectedRide;
|
||||
if (ride == null) return 0.0;
|
||||
return math.max(ride.maxLeanLeft, ride.maxLeanRight);
|
||||
}
|
||||
|
||||
String get _authoritativeLeanSide {
|
||||
final ride = _selectedRide;
|
||||
if (ride == null ||
|
||||
(ride.maxLeanLeft == 0.0 && ride.maxLeanRight == 0.0)) {
|
||||
return 'NONE';
|
||||
}
|
||||
return ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT';
|
||||
}
|
||||
|
||||
int get _authoritativeElevationFt {
|
||||
final ride = _selectedRide;
|
||||
if (ride == null) return 0;
|
||||
return (ride.elevationGain * 3.28084).round();
|
||||
}
|
||||
|
||||
String get _authoritativePaceFactor {
|
||||
final ride = _selectedRide;
|
||||
if (ride == null) return 'N/A';
|
||||
final avgSpeed = ride.averageSpeed > 0
|
||||
? ride.averageSpeed
|
||||
: (_authoritativeDurationSeconds > 0
|
||||
? _authoritativeDistanceMiles /
|
||||
(_authoritativeDurationSeconds / 3600.0)
|
||||
: 0.0);
|
||||
if (avgSpeed >= 45.0) return '1.45x Fast Pace';
|
||||
if (avgSpeed >= 30.0) return '1.25x Spirited';
|
||||
if (avgSpeed > 0.0) return '1.0x Cruiser';
|
||||
return '1.0x Recorded';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialRide != null) {
|
||||
_recentRides = [widget.initialRide!];
|
||||
_applySelectedRideMetadata(widget.initialRide!);
|
||||
if (widget.initialTelemetrySamples != null) {
|
||||
_extractSamples(widget.initialTelemetrySamples!);
|
||||
}
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final defaultBike = ref.read(defaultMotorcycleProvider);
|
||||
if (defaultBike != null) {
|
||||
_bikeController.text =
|
||||
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim();
|
||||
} else {
|
||||
_bikeController.text = '2024 Ducati Streetfighter V4';
|
||||
if (_bikeController.text.isEmpty) {
|
||||
if (defaultBike != null) {
|
||||
_bikeController.text =
|
||||
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'
|
||||
.trim();
|
||||
} else {
|
||||
_bikeController.text = '2024 Ducati Streetfighter V4';
|
||||
}
|
||||
}
|
||||
|
||||
await _loadRecentRides();
|
||||
if (widget.initialRide != null) {
|
||||
if (widget.initialTelemetrySamples == null) {
|
||||
await _loadSamplesForRide(widget.initialRide!);
|
||||
}
|
||||
} else {
|
||||
await _loadRecentRides();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -72,65 +152,105 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
final rides = await (db.select(db.rides)
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.id, mode: drift.OrderingMode.desc)])
|
||||
..orderBy([
|
||||
(t) => drift.OrderingTerm(
|
||||
expression: t.id, mode: drift.OrderingMode.desc)
|
||||
])
|
||||
..limit(10))
|
||||
.get();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_recentRides = rides;
|
||||
if (rides.isNotEmpty) {
|
||||
_selectRide(rides.first);
|
||||
}
|
||||
});
|
||||
if (rides.isNotEmpty) {
|
||||
await _selectRide(rides.first);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore database retrieval errors on rides
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectRide(Ride ride) async {
|
||||
void _applySelectedRideMetadata(Ride ride) {
|
||||
_selectedRide = ride;
|
||||
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
|
||||
final durMins = ride.movingTime > 0
|
||||
? (ride.movingTime / 60).round()
|
||||
: (ride.endTime?.difference(ride.startTime).inMinutes ?? 35).clamp(1, 999);
|
||||
final maxLean = math.max(ride.maxLeanLeft, ride.maxLeanRight);
|
||||
final side = ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT';
|
||||
|
||||
_titleController.text = 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride';
|
||||
_distanceController.text = dist > 0 ? dist.toStringAsFixed(1) : '24.5';
|
||||
_durationController.text = durMins.toString();
|
||||
_maxLeanController.text = maxLean > 0 ? maxLean.toStringAsFixed(0) : '38';
|
||||
_selectedLeanSide = side;
|
||||
_titleController.text =
|
||||
'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride';
|
||||
|
||||
if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) {
|
||||
_bikeController.text =
|
||||
'${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'.trim();
|
||||
'${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
void _extractSamples(List<TelemetrySample> samples) {
|
||||
final validGps = samples
|
||||
.where((s) =>
|
||||
s.latitude != null &&
|
||||
s.longitude != null &&
|
||||
s.latitude!.isFinite &&
|
||||
s.longitude!.isFinite &&
|
||||
(s.latitude != 0.0 || s.longitude != 0.0))
|
||||
.toList();
|
||||
|
||||
List<AppLatLng> downsampledRoute = const [];
|
||||
if (validGps.isNotEmpty) {
|
||||
final routeStep =
|
||||
(validGps.length / 40).ceil().clamp(1, validGps.length);
|
||||
downsampledRoute = [
|
||||
for (int i = 0; i < validGps.length; i += routeStep)
|
||||
AppLatLng(validGps[i].latitude!, validGps[i].longitude!),
|
||||
if ((validGps.length - 1) % routeStep != 0)
|
||||
AppLatLng(validGps.last.latitude!, validGps.last.longitude!),
|
||||
];
|
||||
}
|
||||
|
||||
List<FeedTelemetrySample> downsampledTelemetry = const [];
|
||||
if (samples.isNotEmpty) {
|
||||
final sampleStep =
|
||||
(samples.length / 60).ceil().clamp(1, samples.length);
|
||||
final totalIdx = math.max(1, samples.length - 1);
|
||||
downsampledTelemetry = [
|
||||
for (int i = 0; i < samples.length; i += sampleStep)
|
||||
FeedTelemetrySample(
|
||||
progress: (i / totalIdx).clamp(0.0, 1.0),
|
||||
speedMph: samples[i].speed ?? 0.0,
|
||||
leanAngleDeg: samples[i].leanAngle ?? 0.0,
|
||||
lateralG: samples[i].lateralG ?? 0.0,
|
||||
altitudeFt: (samples[i].altitude ?? 0.0) * 3.28084,
|
||||
latitude: samples[i].latitude,
|
||||
longitude: samples[i].longitude,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
_selectedRoutePoints = downsampledRoute;
|
||||
_selectedTelemetrySamples = downsampledTelemetry;
|
||||
}
|
||||
|
||||
Future<void> _loadSamplesForRide(Ride ride) async {
|
||||
try {
|
||||
final db = ref.read(databaseProvider);
|
||||
final samples = await (db.select(db.telemetrySamples)
|
||||
..where((t) => t.rideId.equals(ride.id))
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.id)]))
|
||||
.get();
|
||||
|
||||
final validSamples = samples.where((s) => s.latitude != null && s.longitude != null).toList();
|
||||
if (validSamples.isNotEmpty) {
|
||||
final step = (validSamples.length / 25).ceil().clamp(1, validSamples.length);
|
||||
final downsampled = [
|
||||
for (int i = 0; i < validSamples.length; i += step)
|
||||
AppLatLng(validSamples[i].latitude!, validSamples[i].longitude!)
|
||||
];
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedRoutePoints = downsampled;
|
||||
});
|
||||
}
|
||||
if (mounted && _selectedRide?.id == ride.id) {
|
||||
setState(() {
|
||||
_extractSamples(samples);
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _selectRide(Ride ride) async {
|
||||
setState(() {
|
||||
_applySelectedRideMetadata(ride);
|
||||
_selectedRoutePoints = const [];
|
||||
_selectedTelemetrySamples = const [];
|
||||
});
|
||||
await _loadSamplesForRide(ride);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -138,9 +258,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
_titleController.dispose();
|
||||
_locationController.dispose();
|
||||
_bikeController.dispose();
|
||||
_distanceController.dispose();
|
||||
_durationController.dispose();
|
||||
_maxLeanController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -155,9 +272,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
final authorDisplayName = user?.displayName ?? 'Apex Rider';
|
||||
final authorHandle = user?.email?.split('@').first ?? 'apex_rider';
|
||||
|
||||
final dist = double.tryParse(_distanceController.text) ?? 35.0;
|
||||
final durMins = int.tryParse(_durationController.text) ?? 45;
|
||||
final lean = double.tryParse(_maxLeanController.text) ?? 45.0;
|
||||
final ride = _selectedRide;
|
||||
|
||||
await ref.read(socialFeedServiceProvider).shareRecentRide(
|
||||
authorUid: authorUid,
|
||||
|
|
@ -174,16 +289,19 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
bikeModel: _bikeController.text.trim().isNotEmpty
|
||||
? _bikeController.text.trim()
|
||||
: '2024 Ducati Streetfighter V4',
|
||||
distanceMiles: dist,
|
||||
durationSeconds: durMins * 60,
|
||||
maxLeanAngleDeg: lean,
|
||||
maxLeanSide: _selectedLeanSide,
|
||||
paceFactor: _selectedPace,
|
||||
distanceMiles: _authoritativeDistanceMiles,
|
||||
durationSeconds: _authoritativeDurationSeconds,
|
||||
maxLeanAngleDeg: _authoritativeMaxLeanDeg,
|
||||
maxLeanSide: _authoritativeLeanSide,
|
||||
paceFactor: _authoritativePaceFactor,
|
||||
hazardCount: 0,
|
||||
elevationFt: 1840,
|
||||
elevationFt: _authoritativeElevationFt,
|
||||
discipline: _selectedDiscipline,
|
||||
snapshotType: FeedSnapshotType.routeTrack,
|
||||
routePoints: _selectedRoutePoints,
|
||||
telemetrySamples: _selectedTelemetrySamples,
|
||||
authoritativeRide: ride,
|
||||
rideId: ride?.id,
|
||||
);
|
||||
|
||||
// Reset filter to All Activity so published session is immediately displayed
|
||||
|
|
@ -303,13 +421,13 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
const Text(
|
||||
'Publish telemetry & route to community feed',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 12,
|
||||
color: colors.outline,
|
||||
color: AppColors.darkOnSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -471,158 +589,82 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Telemetry Metrics Grid (Distance, Duration, Lean)
|
||||
// Read-Only Telemetry Metrics Grid (Distance, Duration, Lean, Side, Pace)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel('DIST (MI)', colors),
|
||||
TextFormField(
|
||||
controller: _distanceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: _buildInputDecoration(hint: '0.0'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
size: 13,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel('TIME (MINS)', colors),
|
||||
TextFormField(
|
||||
controller: _durationController,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: _buildInputDecoration(hint: '0'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel('LEAN (DEG)', colors),
|
||||
TextFormField(
|
||||
controller: _maxLeanController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 14,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
decoration: _buildInputDecoration(hint: '0°'),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
_selectedRide != null
|
||||
? 'RECORDED TELEMETRY (READ-ONLY)'
|
||||
: 'RECORDED TELEMETRY (NO ACTIVE RIDE SELECTED)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.7,
|
||||
color: AppColors.electricCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Lean Side & Pace Segment Selection
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel('LEAN APEX SIDE', colors),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181C21),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF272A30)),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedLeanSide,
|
||||
isExpanded: true,
|
||||
dropdownColor: const Color(0xFF181C21),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'RIGHT', child: Text('RIGHT')),
|
||||
DropdownMenuItem(
|
||||
value: 'LEFT', child: Text('LEFT')),
|
||||
DropdownMenuItem(
|
||||
value: 'PEAK', child: Text('PEAK')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _selectedLeanSide = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildReadOnlyTelemetryTile(
|
||||
key: const ValueKey('share_session_readonly_distance'),
|
||||
label: 'DIST (MI)',
|
||||
value: _authoritativeDistanceMiles.toStringAsFixed(1),
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel('PACE FACTOR', colors),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181C21),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF272A30)),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedPace,
|
||||
isExpanded: true,
|
||||
dropdownColor: const Color(0xFF181C21),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: '1.0x Cruiser',
|
||||
child: Text('1.0x Cruiser')),
|
||||
DropdownMenuItem(
|
||||
value: '1.25x Spirited',
|
||||
child: Text('1.25x Spirited')),
|
||||
DropdownMenuItem(
|
||||
value: '1.45x Fast Pace',
|
||||
child: Text('1.45x Fast Pace')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _selectedPace = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildReadOnlyTelemetryTile(
|
||||
key: const ValueKey('share_session_readonly_duration'),
|
||||
label: 'TIME (MINS)',
|
||||
value: _authoritativeDurationMinutes.toString(),
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _buildReadOnlyTelemetryTile(
|
||||
key: const ValueKey('share_session_readonly_lean'),
|
||||
label: 'LEAN (DEG)',
|
||||
value: '${_authoritativeMaxLeanDeg.toStringAsFixed(0)}°',
|
||||
colors: colors,
|
||||
highlight: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildReadOnlyTelemetryTile(
|
||||
key: const ValueKey('share_session_readonly_lean_side'),
|
||||
label: 'LEAN APEX SIDE',
|
||||
value: _authoritativeLeanSide,
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _buildReadOnlyTelemetryTile(
|
||||
key: const ValueKey('share_session_readonly_pace'),
|
||||
label: 'PACE FACTOR',
|
||||
value: _authoritativePaceFactor,
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -687,6 +729,53 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildReadOnlyTelemetryTile({
|
||||
required Key key,
|
||||
required String label,
|
||||
required String value,
|
||||
required MotorSignalThemeColors colors,
|
||||
bool highlight = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFieldLabel(label, colors),
|
||||
Container(
|
||||
key: key,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF14181E),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF263238)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontTelemetry,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: highlight ? AppColors.electricCyan : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
size: 13,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
|
@ -102,11 +103,31 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
late final MapController _mapController;
|
||||
ll.LatLng? _lastFollowPos;
|
||||
DateTime? _lastFollowTime;
|
||||
Timer? _pendingFollowTimer;
|
||||
|
||||
static const int _followThrottleMs = 250;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = MapController();
|
||||
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||
}
|
||||
|
||||
void _applyFollowMove(ll.LatLng target, double zoom) {
|
||||
_lastFollowPos = target;
|
||||
_lastFollowTime = DateTime.now();
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -124,33 +145,46 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
? 1.0
|
||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||
final hasMoved = posDelta > 0.00001;
|
||||
final elapsedMs = _lastFollowTime == null
|
||||
? _followThrottleMs + 1
|
||||
: now.difference(_lastFollowTime!).inMilliseconds;
|
||||
|
||||
final shouldMove = justBecameFollowMode ||
|
||||
final shouldMoveNow = justBecameFollowMode ||
|
||||
_lastFollowPos == null ||
|
||||
zoomChanged ||
|
||||
(posDelta > 0.00005 &&
|
||||
(_lastFollowTime == null ||
|
||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
||||
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
|
||||
|
||||
if (shouldMove) {
|
||||
_lastFollowPos = centerLl;
|
||||
_lastFollowTime = now;
|
||||
if (shouldMoveNow) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
_applyFollowMove(centerLl, widget.zoom);
|
||||
} else if (hasMoved) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
|
||||
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
|
||||
_pendingFollowTimer = null;
|
||||
if (mounted && widget.isFollowMode) {
|
||||
final latestCenter = ll.LatLng(
|
||||
widget.center.latitude,
|
||||
widget.center.longitude,
|
||||
);
|
||||
_applyFollowMove(latestCenter, widget.zoom);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(centerLl, widget.zoom);
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
|
|
@ -166,6 +200,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import '../../core/theme/map_style.dart';
|
|||
import 'map_provider_interface.dart';
|
||||
|
||||
class GoogleMapProvider implements AppMapProvider {
|
||||
gmaps.GoogleMapController? _controller;
|
||||
|
||||
@override
|
||||
AppMapType get type => AppMapType.googleMaps;
|
||||
|
||||
|
|
@ -25,15 +23,122 @@ class GoogleMapProvider implements AppMapProvider {
|
|||
required ValueChanged<AppLatLng>? onPositionChanged,
|
||||
required VoidCallback? onUserInteraction,
|
||||
}) {
|
||||
final target = gmaps.LatLng(center.latitude, center.longitude);
|
||||
return _GoogleMapView(
|
||||
center: center,
|
||||
zoom: zoom,
|
||||
bearing: bearing,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
isFollowMode: isFollowMode,
|
||||
onPositionChanged: onPositionChanged,
|
||||
onUserInteraction: onUserInteraction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isFollowMode && _controller != null) {
|
||||
_controller!.animateCamera(
|
||||
gmaps.CameraUpdate.newLatLngZoom(target, zoom),
|
||||
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: bearing,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
} finally {
|
||||
_isProgrammaticMove = false;
|
||||
}
|
||||
}
|
||||
|
||||
final gMarkers = markers.map((m) {
|
||||
@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(
|
||||
markerId: gmaps.MarkerId(m.id),
|
||||
position: gmaps.LatLng(m.position.latitude, m.position.longitude),
|
||||
|
|
@ -42,7 +147,7 @@ class GoogleMapProvider implements AppMapProvider {
|
|||
);
|
||||
}).toSet();
|
||||
|
||||
final gPolylines = polylines.map((p) {
|
||||
final gPolylines = widget.polylines.map((p) {
|
||||
return gmaps.Polyline(
|
||||
polylineId: gmaps.PolylineId(p.id),
|
||||
points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(),
|
||||
|
|
@ -58,7 +163,8 @@ class GoogleMapProvider implements AppMapProvider {
|
|||
key: ValueKey('google_map_$sizeKey'),
|
||||
initialCameraPosition: gmaps.CameraPosition(
|
||||
target: target,
|
||||
zoom: zoom,
|
||||
zoom: widget.zoom,
|
||||
bearing: widget.bearing,
|
||||
),
|
||||
markers: gMarkers,
|
||||
polylines: gPolylines,
|
||||
|
|
@ -68,13 +174,21 @@ class GoogleMapProvider implements AppMapProvider {
|
|||
compassEnabled: false,
|
||||
onMapCreated: (controller) {
|
||||
_controller = controller;
|
||||
if (widget.isFollowMode) {
|
||||
_moveCamera(target, widget.zoom, widget.bearing);
|
||||
}
|
||||
},
|
||||
onCameraMoveStarted: () {
|
||||
onUserInteraction?.call();
|
||||
if (!_isProgrammaticMove) {
|
||||
widget.onUserInteraction?.call();
|
||||
}
|
||||
},
|
||||
onCameraMove: (position) {
|
||||
onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude));
|
||||
widget.onPositionChanged?.call(
|
||||
AppLatLng(position.target.latitude, position.target.longitude),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
|
@ -69,11 +70,31 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
late final MapController _mapController;
|
||||
ll.LatLng? _lastFollowPos;
|
||||
DateTime? _lastFollowTime;
|
||||
Timer? _pendingFollowTimer;
|
||||
|
||||
static const int _followThrottleMs = 250;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = MapController();
|
||||
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||
}
|
||||
|
||||
void _applyFollowMove(ll.LatLng target, double zoom) {
|
||||
_lastFollowPos = target;
|
||||
_lastFollowTime = DateTime.now();
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(target, zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -90,33 +111,46 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
? 1.0
|
||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||
final hasMoved = posDelta > 0.00001;
|
||||
final elapsedMs = _lastFollowTime == null
|
||||
? _followThrottleMs + 1
|
||||
: now.difference(_lastFollowTime!).inMilliseconds;
|
||||
|
||||
final shouldMove = justBecameFollowMode ||
|
||||
final shouldMoveNow = justBecameFollowMode ||
|
||||
_lastFollowPos == null ||
|
||||
zoomChanged ||
|
||||
(posDelta > 0.00005 &&
|
||||
(_lastFollowTime == null ||
|
||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
||||
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
|
||||
|
||||
if (shouldMove) {
|
||||
_lastFollowPos = centerLl;
|
||||
_lastFollowTime = now;
|
||||
if (shouldMoveNow) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
_applyFollowMove(centerLl, widget.zoom);
|
||||
} else if (hasMoved) {
|
||||
_pendingFollowTimer?.cancel();
|
||||
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
|
||||
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
|
||||
_pendingFollowTimer = null;
|
||||
if (mounted && widget.isFollowMode) {
|
||||
final latestCenter = ll.LatLng(
|
||||
widget.center.latitude,
|
||||
widget.center.longitude,
|
||||
);
|
||||
_applyFollowMove(latestCenter, widget.zoom);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(centerLl, widget.zoom);
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
|
|
@ -132,6 +166,7 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,10 +134,55 @@ enum FeedSnapshotType {
|
|||
scenicPhoto,
|
||||
}
|
||||
|
||||
/// A recorded telemetry sample point attached to a shared social feed session.
|
||||
@immutable
|
||||
class FeedTelemetrySample {
|
||||
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;
|
||||
|
||||
const FeedTelemetrySample({
|
||||
required this.progress,
|
||||
required this.speedMph,
|
||||
required this.leanAngleDeg,
|
||||
this.lateralG = 0.0,
|
||||
this.altitudeFt = 0.0,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'progress': progress,
|
||||
'speedMph': speedMph,
|
||||
'leanAngleDeg': leanAngleDeg,
|
||||
'lateralG': lateralG,
|
||||
'altitudeFt': altitudeFt,
|
||||
if (latitude != null) 'lat': latitude,
|
||||
if (longitude != null) 'lng': longitude,
|
||||
};
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A community social feed item (Stitch SOC-01).
|
||||
@immutable
|
||||
class SocialFeedItem {
|
||||
final String id;
|
||||
final int? rideId;
|
||||
final String authorUid;
|
||||
final String authorDisplayName;
|
||||
final String authorHandle;
|
||||
|
|
@ -153,7 +198,7 @@ class SocialFeedItem {
|
|||
final double distanceMiles;
|
||||
final int durationSeconds;
|
||||
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 int hazardCount; // e.g. 0
|
||||
final int elevationFt; // e.g. +2140
|
||||
|
|
@ -163,9 +208,10 @@ class SocialFeedItem {
|
|||
final String? regionName;
|
||||
final bool isFeatured;
|
||||
|
||||
// Visual Snapshot Module
|
||||
// Visual Snapshot & Recorded Telemetry Module
|
||||
final FeedSnapshotType snapshotType;
|
||||
final List<AppLatLng> routePoints;
|
||||
final List<FeedTelemetrySample> telemetrySamples;
|
||||
final String? photoUrl;
|
||||
final String? photoCaption;
|
||||
final bool isGpsVerified;
|
||||
|
|
@ -178,6 +224,7 @@ class SocialFeedItem {
|
|||
|
||||
const SocialFeedItem({
|
||||
required this.id,
|
||||
this.rideId,
|
||||
required this.authorUid,
|
||||
required this.authorDisplayName,
|
||||
required this.authorHandle,
|
||||
|
|
@ -194,21 +241,80 @@ class SocialFeedItem {
|
|||
this.maxLeanSide = 'RIGHT',
|
||||
this.paceFactor = '1.2x Spirited',
|
||||
this.hazardCount = 0,
|
||||
this.elevationFt = 1200,
|
||||
this.elevationFt = 0,
|
||||
this.discipline = RidingDiscipline.canyonSport,
|
||||
this.regionName,
|
||||
this.isFeatured = false,
|
||||
this.snapshotType = FeedSnapshotType.routeTrack,
|
||||
this.routePoints = const [],
|
||||
this.telemetrySamples = const [],
|
||||
this.photoUrl,
|
||||
this.photoCaption,
|
||||
this.isGpsVerified = true,
|
||||
this.isGpsVerified = false,
|
||||
this.isDemo = false,
|
||||
this.fistBumpCount = 0,
|
||||
this.fistBumpUserIds = const [],
|
||||
this.comments = const [],
|
||||
});
|
||||
|
||||
/// Evaluates whether a set of ride metrics and GPS route coordinates satisfy
|
||||
/// authentic GPS verification criteria.
|
||||
static bool verifyGpsCriteria({
|
||||
required bool isDemo,
|
||||
required double distanceMiles,
|
||||
required int durationSeconds,
|
||||
required List<AppLatLng> routePoints,
|
||||
}) {
|
||||
if (isDemo) return false;
|
||||
if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false;
|
||||
if (durationSeconds <= 0) 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();
|
||||
|
||||
if (validPoints.length < 2) return false;
|
||||
|
||||
// Require at least two distinct GPS coordinates with meaningful displacement (~50m+).
|
||||
final first = validPoints.first;
|
||||
final hasDistinctPoint = validPoints.skip(1).any(
|
||||
(p) =>
|
||||
(p.latitude - first.latitude).abs() +
|
||||
(p.longitude - first.longitude).abs() >
|
||||
0.0005,
|
||||
);
|
||||
return hasDistinctPoint;
|
||||
}
|
||||
|
||||
/// True only when this post is flagged as GPS-verified AND backed by valid,
|
||||
/// non-demo GPS route coordinates, positive distance, and positive duration.
|
||||
bool get hasVerifiedGps =>
|
||||
isGpsVerified &&
|
||||
verifyGpsCriteria(
|
||||
isDemo: isDemo,
|
||||
distanceMiles: distanceMiles,
|
||||
durationSeconds: durationSeconds,
|
||||
routePoints: routePoints,
|
||||
);
|
||||
|
||||
/// 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) {
|
||||
if (uid == null) return false;
|
||||
return fistBumpUserIds.contains(uid);
|
||||
|
|
@ -216,6 +322,7 @@ class SocialFeedItem {
|
|||
|
||||
SocialFeedItem copyWith({
|
||||
String? id,
|
||||
int? rideId,
|
||||
String? authorUid,
|
||||
String? authorDisplayName,
|
||||
String? authorHandle,
|
||||
|
|
@ -238,6 +345,7 @@ class SocialFeedItem {
|
|||
bool? isFeatured,
|
||||
FeedSnapshotType? snapshotType,
|
||||
List<AppLatLng>? routePoints,
|
||||
List<FeedTelemetrySample>? telemetrySamples,
|
||||
String? photoUrl,
|
||||
String? photoCaption,
|
||||
bool? isGpsVerified,
|
||||
|
|
@ -248,6 +356,7 @@ class SocialFeedItem {
|
|||
}) {
|
||||
return SocialFeedItem(
|
||||
id: id ?? this.id,
|
||||
rideId: rideId ?? this.rideId,
|
||||
authorUid: authorUid ?? this.authorUid,
|
||||
authorDisplayName: authorDisplayName ?? this.authorDisplayName,
|
||||
authorHandle: authorHandle ?? this.authorHandle,
|
||||
|
|
@ -270,6 +379,7 @@ class SocialFeedItem {
|
|||
isFeatured: isFeatured ?? this.isFeatured,
|
||||
snapshotType: snapshotType ?? this.snapshotType,
|
||||
routePoints: routePoints ?? this.routePoints,
|
||||
telemetrySamples: telemetrySamples ?? this.telemetrySamples,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
photoCaption: photoCaption ?? this.photoCaption,
|
||||
isGpsVerified: isGpsVerified ?? this.isGpsVerified,
|
||||
|
|
@ -282,6 +392,7 @@ class SocialFeedItem {
|
|||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
if (rideId != null) 'rideId': rideId,
|
||||
'authorUid': authorUid,
|
||||
'authorDisplayName': authorDisplayName,
|
||||
'authorHandle': authorHandle,
|
||||
|
|
@ -303,10 +414,14 @@ class SocialFeedItem {
|
|||
if (regionName != null) 'regionName': regionName,
|
||||
'isFeatured': isFeatured,
|
||||
'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 (photoCaption != null) 'photoCaption': photoCaption,
|
||||
'isGpsVerified': isGpsVerified,
|
||||
'isGpsVerified': hasVerifiedGps,
|
||||
'isDemo': isDemo,
|
||||
'fistBumpCount': fistBumpCount,
|
||||
'fistBumpUserIds': fistBumpUserIds,
|
||||
|
|
@ -326,14 +441,33 @@ class SocialFeedItem {
|
|||
}
|
||||
}
|
||||
|
||||
final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? [];
|
||||
final samples = telemetryList
|
||||
.whereType<Map>()
|
||||
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s)))
|
||||
.toList();
|
||||
|
||||
final commentList = json['comments'] as List<dynamic>? ?? [];
|
||||
final comments = commentList
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((c) => FeedPostComment.fromJson(c))
|
||||
.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,
|
||||
);
|
||||
|
||||
return SocialFeedItem(
|
||||
id: json['id'] as String? ?? '',
|
||||
rideId: (json['rideId'] as num?)?.toInt(),
|
||||
authorUid: json['authorUid'] as String? ?? '',
|
||||
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
|
||||
authorHandle: json['authorHandle'] as String? ?? 'rider',
|
||||
|
|
@ -344,8 +478,8 @@ class SocialFeedItem {
|
|||
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
|
||||
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
|
||||
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
|
||||
distanceMiles: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0,
|
||||
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
|
||||
distanceMiles: distanceMiles,
|
||||
durationSeconds: durationSeconds,
|
||||
maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0,
|
||||
maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT',
|
||||
paceFactor: json['paceFactor'] as String? ?? '1.0x',
|
||||
|
|
@ -363,13 +497,16 @@ class SocialFeedItem {
|
|||
? FeedSnapshotType.scenicPhoto
|
||||
: FeedSnapshotType.routeTrack,
|
||||
routePoints: points,
|
||||
telemetrySamples: samples,
|
||||
photoUrl: json['photoUrl'] as String?,
|
||||
photoCaption: json['photoCaption'] as String?,
|
||||
isGpsVerified: json['isGpsVerified'] as bool? ?? true,
|
||||
isDemo: json['isDemo'] as bool? ?? false,
|
||||
isGpsVerified: verified,
|
||||
isDemo: isDemo,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../map/map_provider_interface.dart';
|
||||
import 'social_feed_models.dart';
|
||||
|
||||
|
|
@ -363,7 +365,7 @@ class SocialFeedService {
|
|||
result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList();
|
||||
}
|
||||
if (verifiedGpsOnly == true) {
|
||||
result = result.where((i) => i.isGpsVerified).toList();
|
||||
result = result.where((i) => i.hasVerifiedGps).toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -433,6 +435,11 @@ class SocialFeedService {
|
|||
}
|
||||
|
||||
/// 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({
|
||||
required String authorUid,
|
||||
required String authorDisplayName,
|
||||
|
|
@ -443,21 +450,71 @@ class SocialFeedService {
|
|||
required String locationName,
|
||||
required String rideTitle,
|
||||
required String bikeModel,
|
||||
required double distanceMiles,
|
||||
required int durationSeconds,
|
||||
required double maxLeanAngleDeg,
|
||||
double distanceMiles = 0.0,
|
||||
int durationSeconds = 0,
|
||||
double maxLeanAngleDeg = 0.0,
|
||||
String maxLeanSide = 'RIGHT',
|
||||
String paceFactor = '1.2x Spirited',
|
||||
String paceFactor = '1.0x Cruiser',
|
||||
int hazardCount = 0,
|
||||
int elevationFt = 1500,
|
||||
int elevationFt = 0,
|
||||
RidingDiscipline discipline = RidingDiscipline.canyonSport,
|
||||
String? regionName,
|
||||
bool isFeatured = false,
|
||||
FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack,
|
||||
List<AppLatLng> routePoints = const [],
|
||||
List<FeedTelemetrySample> telemetrySamples = const [],
|
||||
Ride? authoritativeRide,
|
||||
int? rideId,
|
||||
bool? isGpsVerified,
|
||||
}) 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 meetsGpsVerification = SocialFeedItem.verifyGpsCriteria(
|
||||
isDemo: false,
|
||||
distanceMiles: resolvedDistance,
|
||||
durationSeconds: resolvedDurationSeconds,
|
||||
routePoints: routePoints,
|
||||
);
|
||||
final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification;
|
||||
|
||||
final newPost = SocialFeedItem(
|
||||
id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}',
|
||||
rideId: resolvedRideId,
|
||||
authorUid: authorUid,
|
||||
authorDisplayName: authorDisplayName,
|
||||
authorHandle: authorHandle,
|
||||
|
|
@ -468,19 +525,21 @@ class SocialFeedService {
|
|||
timestamp: DateTime.now(),
|
||||
rideTitle: rideTitle,
|
||||
bikeModel: bikeModel,
|
||||
distanceMiles: distanceMiles,
|
||||
durationSeconds: durationSeconds,
|
||||
maxLeanAngleDeg: maxLeanAngleDeg,
|
||||
maxLeanSide: maxLeanSide,
|
||||
distanceMiles: resolvedDistance,
|
||||
durationSeconds: resolvedDurationSeconds,
|
||||
maxLeanAngleDeg: resolvedMaxLean,
|
||||
maxLeanSide: resolvedLeanSide,
|
||||
paceFactor: paceFactor,
|
||||
hazardCount: hazardCount,
|
||||
elevationFt: elevationFt,
|
||||
elevationFt: resolvedElevationFt,
|
||||
discipline: discipline,
|
||||
regionName: regionName,
|
||||
isFeatured: isFeatured,
|
||||
snapshotType: snapshotType,
|
||||
routePoints: routePoints,
|
||||
isGpsVerified: true,
|
||||
telemetrySamples: telemetrySamples,
|
||||
isGpsVerified: effectiveGpsVerified,
|
||||
isDemo: false,
|
||||
fistBumpCount: 1,
|
||||
fistBumpUserIds: [authorUid],
|
||||
comments: const [],
|
||||
|
|
@ -504,3 +563,4 @@ class SocialFeedService {
|
|||
_localFeedController.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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_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';
|
||||
|
||||
void main() {
|
||||
|
|
@ -344,6 +347,166 @@ void main() {
|
|||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestRideStateMachine extends StateNotifier<RideSession>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart';
|
||||
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
|
||||
import 'package:motorcycle_dash/features/social/feed/widgets/feed_comments_bottom_sheet.dart';
|
||||
import 'package:motorcycle_dash/features/social/feed/widgets/feed_telemetry_inspector_modal.dart';
|
||||
import 'package:motorcycle_dash/features/social/feed/widgets/share_session_modal.dart';
|
||||
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
|
||||
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
||||
|
|
@ -385,4 +387,597 @@ void main() {
|
|||
expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('BUG-003: Share Recent Session Read-Only Telemetry Integrity Tests', () {
|
||||
test(
|
||||
'SocialFeedService.shareRecentRide enforces authoritativeRide telemetry over forged input parameters',
|
||||
() async {
|
||||
final service = SocialFeedService();
|
||||
addTearDown(service.dispose);
|
||||
|
||||
final recordedRide = Ride(
|
||||
id: 77,
|
||||
startTime: DateTime(2026, 10, 10, 8, 0),
|
||||
endTime: DateTime(2026, 10, 10, 8, 42),
|
||||
distance: 31.4,
|
||||
distanceMiles: 31.4,
|
||||
movingTime: 2520, // 42 mins
|
||||
stoppedTime: 0,
|
||||
topSpeed: 74.5,
|
||||
topSpeedMph: 74.5,
|
||||
averageSpeed: 44.8,
|
||||
maxLeanLeft: 47.0,
|
||||
maxLeanRight: 39.0,
|
||||
maxAccelG: 0.55,
|
||||
maxBrakingG: 0.62,
|
||||
elevationGain: 300.0, // ~984 ft
|
||||
elevationLoss: 100.0,
|
||||
);
|
||||
|
||||
final published = await service.shareRecentRide(
|
||||
authorUid: 'uid_1',
|
||||
authorDisplayName: 'Test Rider',
|
||||
authorHandle: 'test_rider',
|
||||
locationName: 'Highway 9',
|
||||
rideTitle: 'Morning Run',
|
||||
bikeModel: 'Yamaha R1',
|
||||
// Attempt to pass forged telemetry values alongside authoritativeRide
|
||||
distanceMiles: 999.9,
|
||||
durationSeconds: 99999,
|
||||
maxLeanAngleDeg: 88.0,
|
||||
maxLeanSide: 'RIGHT',
|
||||
elevationFt: 50000,
|
||||
authoritativeRide: recordedRide,
|
||||
routePoints: const [
|
||||
AppLatLng(37.2500, -122.1200),
|
||||
AppLatLng(37.2900, -122.1600),
|
||||
],
|
||||
);
|
||||
|
||||
expect(published.rideId, 77);
|
||||
expect(published.distanceMiles, closeTo(31.4, 0.001));
|
||||
expect(published.durationSeconds, 2520);
|
||||
expect(published.maxLeanAngleDeg, closeTo(47.0, 0.001));
|
||||
expect(published.maxLeanSide, 'LEFT');
|
||||
expect(published.elevationFt, (300.0 * 3.28084).round());
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'ShareSessionModal renders telemetry as read-only tiles and publishes authoritative Ride values',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 900));
|
||||
|
||||
final service = SocialFeedService();
|
||||
addTearDown(service.dispose);
|
||||
|
||||
final recordedRide = Ride(
|
||||
id: 42,
|
||||
startTime: DateTime(2026, 10, 12, 9, 0),
|
||||
endTime: DateTime(2026, 10, 12, 9, 42),
|
||||
distance: 31.4,
|
||||
distanceMiles: 31.4,
|
||||
movingTime: 2520, // 42 mins
|
||||
stoppedTime: 0,
|
||||
topSpeed: 78.0,
|
||||
topSpeedMph: 78.0,
|
||||
averageSpeed: 45.2,
|
||||
maxLeanLeft: 47.0,
|
||||
maxLeanRight: 41.0,
|
||||
maxAccelG: 0.5,
|
||||
maxBrakingG: 0.6,
|
||||
elevationGain: 450.0,
|
||||
elevationLoss: 150.0,
|
||||
motorcycleYear: 2024,
|
||||
motorcycleMake: 'Aprilia',
|
||||
motorcycleModel: 'RSV4 Factory',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_createTestApp(
|
||||
overrides: [
|
||||
socialFeedServiceProvider.overrideWithValue(service),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => ShareSessionModal.show(
|
||||
context,
|
||||
initialRide: recordedRide,
|
||||
),
|
||||
child: const Text('Open Share Modal'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Open Share Modal'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Only 3 editable TextFormFields exist (Title, Motorcycle Model, Location)
|
||||
// Telemetry fields (Distance, Time, Lean) are NOT editable TextFormFields
|
||||
expect(find.byType(TextFormField), findsNWidgets(3));
|
||||
|
||||
// Verify read-only telemetry tiles display the authoritative Ride metrics
|
||||
expect(
|
||||
find.byKey(const ValueKey('share_session_readonly_distance')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('share_session_readonly_duration')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('share_session_readonly_lean')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('share_session_readonly_lean_side')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('31.4'), findsOneWidget);
|
||||
expect(find.text('42'), findsOneWidget);
|
||||
expect(find.text('47°'), findsOneWidget);
|
||||
expect(find.text('LEFT'), findsOneWidget);
|
||||
|
||||
// Edit metadata (Title) and publish
|
||||
await tester.enterText(
|
||||
find.byType(TextFormField).first,
|
||||
'Verified Highway 9 Dawn Patrol',
|
||||
);
|
||||
await tester.tap(find.text('PUBLISH TO COMMUNITY FEED'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final feedItems = await service.getFeedStream().first;
|
||||
final latest = feedItems.first;
|
||||
expect(latest.rideTitle, 'Verified Highway 9 Dawn Patrol');
|
||||
expect(latest.distanceMiles, closeTo(31.4, 0.001));
|
||||
expect(latest.durationSeconds, 2520);
|
||||
expect(latest.maxLeanAngleDeg, closeTo(47.0, 0.001));
|
||||
expect(latest.maxLeanSide, 'LEFT');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('BUG-004: Published Ride Real Telemetry & GPS Verified Badge Tests', () {
|
||||
test(
|
||||
'SocialFeedItem.verifyGpsCriteria and hasVerifiedGps reject demo posts, forged JSON, and zero-displacement routes',
|
||||
() {
|
||||
// 1. Demo post with valid routePoints still must NOT be GPS verified
|
||||
final demoPost = SocialFeedItem(
|
||||
id: 'demo_1',
|
||||
authorUid: 'uid_1',
|
||||
authorDisplayName: 'Demo Rider',
|
||||
authorHandle: 'demo',
|
||||
locationName: 'Skyline',
|
||||
timestamp: DateTime(2026, 10, 12),
|
||||
rideTitle: 'Demo Ride',
|
||||
bikeModel: 'R1',
|
||||
distanceMiles: 40.0,
|
||||
durationSeconds: 2400,
|
||||
maxLeanAngleDeg: 45.0,
|
||||
isDemo: true,
|
||||
isGpsVerified: true,
|
||||
routePoints: const [
|
||||
AppLatLng(37.3861, -122.2641),
|
||||
AppLatLng(37.2500, -122.1800),
|
||||
],
|
||||
);
|
||||
expect(demoPost.hasVerifiedGps, isFalse);
|
||||
|
||||
// 2. Non-demo post with < 2 points or zero displacement (< 100m) must NOT be GPS verified
|
||||
final stationaryPost = SocialFeedItem(
|
||||
id: 'stat_1',
|
||||
authorUid: 'uid_1',
|
||||
authorDisplayName: 'Rider',
|
||||
authorHandle: 'rider',
|
||||
locationName: 'Garage',
|
||||
timestamp: DateTime(2026, 10, 12),
|
||||
rideTitle: 'Garage Idle',
|
||||
bikeModel: 'R1',
|
||||
distanceMiles: 5.0,
|
||||
durationSeconds: 600,
|
||||
maxLeanAngleDeg: 10.0,
|
||||
isDemo: false,
|
||||
isGpsVerified: true,
|
||||
routePoints: const [
|
||||
AppLatLng(37.38610, -122.26410),
|
||||
AppLatLng(37.38611, -122.26411),
|
||||
],
|
||||
);
|
||||
expect(stationaryPost.hasVerifiedGps, isFalse);
|
||||
|
||||
// 3. Untrusted Firestore JSON claiming isGpsVerified: true with empty routePoints is rejected
|
||||
final forgedFromJson = SocialFeedItem.fromJson({
|
||||
'id': 'forged_1',
|
||||
'authorUid': 'uid_forger',
|
||||
'authorDisplayName': 'Forger',
|
||||
'authorHandle': 'forger',
|
||||
'locationName': 'Nowhere',
|
||||
'timestamp': '2026-10-12T10:00:00.000Z',
|
||||
'rideTitle': 'Fake Verified Run',
|
||||
'bikeModel': 'H2R',
|
||||
'distanceMiles': 100.0,
|
||||
'durationSeconds': 3600,
|
||||
'maxLeanAngleDeg': 60.0,
|
||||
'isDemo': false,
|
||||
'isGpsVerified': true,
|
||||
'routePoints': <Map<String, dynamic>>[],
|
||||
});
|
||||
expect(forgedFromJson.isGpsVerified, isFalse);
|
||||
expect(forgedFromJson.hasVerifiedGps, isFalse);
|
||||
|
||||
// 4. Real non-demo ride with >= 2 distinct GPS coordinates (>= 100m), positive distance & duration is verified
|
||||
final validPost = SocialFeedItem(
|
||||
id: 'valid_1',
|
||||
authorUid: 'uid_real',
|
||||
authorDisplayName: 'Real Rider',
|
||||
authorHandle: 'realrider',
|
||||
locationName: 'Highway 9',
|
||||
timestamp: DateTime(2026, 10, 12),
|
||||
rideTitle: 'Real Canyon Run',
|
||||
bikeModel: 'Street Triple RS',
|
||||
distanceMiles: 24.5,
|
||||
durationSeconds: 1800,
|
||||
maxLeanAngleDeg: 44.0,
|
||||
isDemo: false,
|
||||
isGpsVerified: true,
|
||||
routePoints: const [
|
||||
AppLatLng(37.2500, -122.1200),
|
||||
AppLatLng(37.2900, -122.1600),
|
||||
],
|
||||
);
|
||||
expect(validPost.hasVerifiedGps, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Unverified/demo feed posts hide GPS VERIFIED badge and show explicit empty telemetry state in inspector',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1000));
|
||||
|
||||
final service = SocialFeedService();
|
||||
addTearDown(service.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_createTestApp(
|
||||
overrides: [
|
||||
socialFeedServiceProvider.overrideWithValue(service),
|
||||
],
|
||||
child: const SocialFeedScreen(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
// Default feed items are demo items (isDemo: true), so GPS VERIFIED badge must NOT appear
|
||||
expect(find.text('GPS VERIFIED'), findsNothing);
|
||||
expect(find.text('NO GPS TRACK RECORDED'), findsWidgets);
|
||||
|
||||
// Open telemetry inspector on unverified/demo post
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('INSPECT TELEMETRY GRAPH').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Must show explicit no-telemetry-samples state instead of synthetic sine wave
|
||||
expect(
|
||||
find.byKey(const ValueKey('inspector_no_telemetry_state')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('NO RECORDED TELEMETRY SAMPLES'), findsOneWidget);
|
||||
expect(find.text('TELEMETRY TRACE UNAVAILABLE'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'Verified ride with recorded telemetrySamples renders GPS VERIFIED badge and real interpolated scrubber values',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1000));
|
||||
|
||||
final verifiedItem = SocialFeedItem(
|
||||
id: 'verified_post_99',
|
||||
rideId: 99,
|
||||
authorUid: 'uid_verified',
|
||||
authorDisplayName: 'Elena Rostova',
|
||||
authorHandle: 'ElenaApex',
|
||||
locationName: 'Angeles Crest Hwy',
|
||||
timestamp: DateTime.now(),
|
||||
rideTitle: 'Verified Morning Telemetry Run',
|
||||
bikeModel: '2024 Aprilia Tuono V4',
|
||||
distanceMiles: 20.0,
|
||||
durationSeconds: 1500,
|
||||
maxLeanAngleDeg: 46.0,
|
||||
maxLeanSide: 'LEFT',
|
||||
elevationFt: 1800,
|
||||
isDemo: false,
|
||||
isGpsVerified: true,
|
||||
routePoints: const [
|
||||
AppLatLng(34.2000, -118.1500),
|
||||
AppLatLng(34.2400, -118.1100),
|
||||
AppLatLng(34.2800, -118.0700),
|
||||
],
|
||||
telemetrySamples: const [
|
||||
FeedTelemetrySample(
|
||||
progress: 0.0,
|
||||
speedMph: 35.0,
|
||||
leanAngleDeg: 10.0,
|
||||
lateralG: 0.18,
|
||||
altitudeFt: 1200.0,
|
||||
latitude: 34.2000,
|
||||
longitude: -118.1500,
|
||||
),
|
||||
FeedTelemetrySample(
|
||||
progress: 0.5,
|
||||
speedMph: 63.0,
|
||||
leanAngleDeg: -46.0,
|
||||
lateralG: -0.84,
|
||||
altitudeFt: 2400.0,
|
||||
latitude: 34.2400,
|
||||
longitude: -118.1100,
|
||||
),
|
||||
FeedTelemetrySample(
|
||||
progress: 1.0,
|
||||
speedMph: 42.0,
|
||||
leanAngleDeg: 15.0,
|
||||
lateralG: 0.25,
|
||||
altitudeFt: 3000.0,
|
||||
latitude: 34.2800,
|
||||
longitude: -118.0700,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_createTestApp(
|
||||
child: Builder(
|
||||
builder: (context) => Column(
|
||||
children: [
|
||||
if (verifiedItem.hasVerifiedGps)
|
||||
const Text('GPS VERIFIED'),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
FeedTelemetryInspectorModal.show(context, verifiedItem),
|
||||
child: const Text('Inspect Verified Ride'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(verifiedItem.hasVerifiedGps, isTrue);
|
||||
expect(find.text('GPS VERIFIED'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Inspect Verified Ride'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No empty-state overlay when real telemetry samples exist
|
||||
expect(
|
||||
find.byKey(const ValueKey('inspector_no_telemetry_state')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
// At default _scrubFraction = 0.5, scrubber readout matches sample[1] (63 MPH, 46°, 0.84G)
|
||||
expect(find.text('POINT: 10.0 MI (50%)'), findsOneWidget);
|
||||
expect(find.text('63 MPH'), findsOneWidget);
|
||||
expect(find.text('46°'), findsWidgets);
|
||||
expect(find.text('0.84G'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('BUG-002: Social Feed Typography & Theme Contrast Tests', () {
|
||||
double contrastRatio(Color fg, Color bg) {
|
||||
final l1 = fg.computeLuminance();
|
||||
final l2 = bg.computeLuminance();
|
||||
final brightest = l1 > l2 ? l1 : l2;
|
||||
final darkest = l1 > l2 ? l2 : l1;
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
|
||||
for (final isLight in [false, true]) {
|
||||
testWidgets(
|
||||
'SocialFeedScreen and FeedCommentsBottomSheet maintain WCAG AA (>= 4.5:1) contrast in ${isLight ? "Light" : "Dark"} mode',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(412, 915));
|
||||
|
||||
final themeColors = isLight
|
||||
? MotorSignalThemeColors.light
|
||||
: MotorSignalThemeColors.dark;
|
||||
final baseTheme = isLight ? ThemeData.light() : ThemeData.dark();
|
||||
|
||||
final service = SocialFeedService();
|
||||
addTearDown(service.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
socialFeedServiceProvider.overrideWithValue(service),
|
||||
],
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
size: Size(412, 915),
|
||||
textScaler: TextScaler.linear(1.2),
|
||||
),
|
||||
child: MaterialApp(
|
||||
theme: baseTheme.copyWith(
|
||||
extensions: [themeColors],
|
||||
),
|
||||
home: const SocialFeedScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// 1. AppBar title contrast against canvas surface
|
||||
final appBarTitle = tester.widget<Text>(find.text('Community Feed'));
|
||||
expect(
|
||||
contrastRatio(appBarTitle.style!.color!, themeColors.surfaceCanvas),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
|
||||
// 2. Card primary text (@authorHandle and rideTitle) against card primary surface
|
||||
final handleText = tester.widget<Text>(find.text('@ApexMarcus'));
|
||||
expect(
|
||||
contrastRatio(
|
||||
handleText.style!.color!,
|
||||
themeColors.surfaceCardPrimary,
|
||||
),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
|
||||
final rideTitleText = tester.widget<Text>(
|
||||
find.text('Sunday Skyline & Pescadero Carve'),
|
||||
);
|
||||
expect(
|
||||
contrastRatio(
|
||||
rideTitleText.style!.color!,
|
||||
themeColors.surfaceCardPrimary,
|
||||
),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
|
||||
// 3. Card secondary text (clubName) against card primary surface
|
||||
final clubText = tester.widget<Text>(
|
||||
find.text('Bay Area Twisties Club'),
|
||||
);
|
||||
expect(
|
||||
contrastRatio(
|
||||
clubText.style!.color!,
|
||||
themeColors.surfaceCardPrimary,
|
||||
),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
|
||||
// 4. Filter tabs (selected vs unselected) contrast
|
||||
final selectedFilterText = tester.widget<Text>(
|
||||
find.text('All Activity'),
|
||||
);
|
||||
final selectedFilterBg = isLight
|
||||
? const Color(0xFF0047B3)
|
||||
: const Color(0xFF00E5FF);
|
||||
expect(
|
||||
contrastRatio(selectedFilterText.style!.color!, selectedFilterBg),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
|
||||
final unselectedFilterText = tester.widget<Text>(
|
||||
find.text('Club Only'),
|
||||
);
|
||||
expect(
|
||||
contrastRatio(
|
||||
unselectedFilterText.style!.color!,
|
||||
themeColors.surfaceCardSecondary,
|
||||
),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
|
||||
// 5. Telemetry metric labels and values contrast against metric cell surface
|
||||
final distLabel = tester.widget<Text>(find.text('DISTANCE').first);
|
||||
expect(
|
||||
contrastRatio(
|
||||
distLabel.style!.color!,
|
||||
themeColors.surfaceCardPrimary,
|
||||
),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
|
||||
final leanValue = tester.widget<Text>(find.text('51°').first);
|
||||
expect(
|
||||
contrastRatio(
|
||||
leanValue.style!.color!,
|
||||
themeColors.surfaceCardPrimary,
|
||||
),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
group('BUG-005: GPX Export Snackbar Horizontal Overflow Tests', () {
|
||||
for (final viewport in const [
|
||||
Size(320, 640),
|
||||
Size(360, 800),
|
||||
Size(800, 480),
|
||||
]) {
|
||||
testWidgets(
|
||||
'GPX export snackbar renders long rideTitle without RenderFlex overflow at ${viewport.width.toInt()}x${viewport.height.toInt()} (1.5x text scale)',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(viewport);
|
||||
|
||||
final service = SocialFeedService();
|
||||
addTearDown(service.dispose);
|
||||
|
||||
const longRideTitle =
|
||||
'Ultra Long Sunday Skyline Boulevard & Pescadero Creek Canyon Telemetry Endurance Carve Session With Full Squad Pack';
|
||||
|
||||
final post = await service.shareRecentRide(
|
||||
authorUid: 'uid_overflow_test',
|
||||
authorDisplayName: 'Marcus Vance',
|
||||
authorHandle: 'ApexMarcus',
|
||||
locationName: 'Skyline Blvd',
|
||||
rideTitle: longRideTitle,
|
||||
bikeModel: '2024 Ducati Streetfighter V4',
|
||||
distanceMiles: 68.4,
|
||||
durationSeconds: 4440,
|
||||
maxLeanAngleDeg: 51.0,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
socialFeedServiceProvider.overrideWithValue(service),
|
||||
],
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(
|
||||
size: viewport,
|
||||
textScaler: const TextScaler.linear(1.5),
|
||||
),
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.dark().copyWith(
|
||||
extensions: const [MotorSignalThemeColors.dark],
|
||||
),
|
||||
home: const SocialFeedScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
final gpxButton = find.byKey(
|
||||
ValueKey('gpx_export_button_${post.id}'),
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
gpxButton,
|
||||
120.0,
|
||||
scrollable: find.descendant(
|
||||
of: find.byKey(const ValueKey('social_feed_list_view')),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(gpxButton);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
// Must not throw any RenderFlex overflow exception
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
find.text('GPX track exported for $longRideTitle'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue