fix(android): resolve BUG-001 through BUG-005 (#78)
* fix(dashboard): ensure Touring map follows rider and supports recenter (#48) * fix(social): make shared session telemetry read-only and authoritative (#50) * fix(social): use recorded telemetry for feed charts and verify GPS badge (#51) * fix(social): standardize community feed typography and theme colors (#49) * fix(social): prevent horizontal overflow in GPX export snackbar (#52) - Wrap GPX export snackbar message Text in Expanded with maxLines: 2 and TextOverflow.ellipsis - Add ValueKey('gpx_export_button_<id>') for deterministic testing - Wrap narrow feed header/metric/empty-state Row labels in Flexible to prevent overflow under 320px/360px at 1.5x text scale - Add BUG-005 widget tests across 320x640, 360x800, and 800x480 viewports at 1.5x text scale * fix(social): harden share modal, inspector theming, and PR #78 accuracy (#49, #50, #51, #52) - Require a selected recorded Ride in ShareSessionModal before enabling publish - Theme ShareSessionModal and FeedTelemetryInspectorModal for Light and Dark modes - Use LayoutBuilder width for FeedTelemetryInspectorModal scrubber drag/tap - Route PopupMenuButton export_gpx action through the overflow-safe GPX snackbar - Enforce telemetrySamples >= 2 when provided to verifyGpsCriteria * fix(android): remediate map follow smoothness, orientation controls, starting location, and landscape overflow (#79, #80, #81, #52) * fix(android): stop Pro upsell sheet overflowing in landscape (#52 / BUG-005) The "Forensic CSV & PDF Export" Pro upsell sheet showed "BOTTOM OVERFLOWED BY 90 PIXELS" in landscape on a Pixel 8a. showProUpgradeSheet built a non-scrolling Column inside a default modal bottom sheet, which Flutter caps at 9/16 of the screen height (about 230 dp in landscape) - shorter than the header, description and two actions. Use isScrollControlled and wrap the content in a SingleChildScrollView so the sheet can take the full height and never overflows. This also covers every other caller (garage, add motorcycle, ride detail). Tests: pro_upgrade_sheet_landscape_test covers 914x411 (Pixel 8a), 800x360 and 780x360 at 1.0x and 1.3x text scale plus NOT NOW dismissal. Verified to fail on the old code (RenderFlex overflowed 64-144 px) and pass on the fix. Full suite 1700 passed, flutter analyze clean. Not validated on the physical device yet. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(social): require telemetry evidence, geodesic GPS threshold, and 3 MPH lean gate (#50, #51) --------- Co-authored-by: Developer <dev@example.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2ca069fdb9
commit
b005bb00ae
|
|
@ -5,15 +5,18 @@ import '../../../core/theme/app_typography.dart';
|
||||||
import '../../../services/alerts/road_alert_models.dart';
|
import '../../../services/alerts/road_alert_models.dart';
|
||||||
import '../../../services/alerts/road_alert_service.dart';
|
import '../../../services/alerts/road_alert_service.dart';
|
||||||
import '../../../services/map/map_provider_interface.dart';
|
import '../../../services/map/map_provider_interface.dart';
|
||||||
|
import '../../../services/map/map_providers.dart';
|
||||||
import '../../../services/map/unified_map_view.dart';
|
import '../../../services/map/unified_map_view.dart';
|
||||||
import '../../../services/navigation/navigation_session_service.dart';
|
import '../../../services/navigation/navigation_session_service.dart';
|
||||||
import '../../../services/telemetry_service.dart';
|
import '../../../services/telemetry_service.dart';
|
||||||
|
import '../../rides/ride_state_machine.dart';
|
||||||
|
|
||||||
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
|
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
|
||||||
///
|
///
|
||||||
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
|
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
|
||||||
/// and alert pins via [activeAlertsStreamProvider].
|
/// and alert pins via [activeAlertsStreamProvider], maintains camera follow
|
||||||
class DashboardMapPanel extends ConsumerWidget {
|
/// state, and provides a recenter control after manual pan.
|
||||||
|
class DashboardMapPanel extends ConsumerStatefulWidget {
|
||||||
final double zoom;
|
final double zoom;
|
||||||
|
|
||||||
const DashboardMapPanel({
|
const DashboardMapPanel({
|
||||||
|
|
@ -22,7 +25,43 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<DashboardMapPanel> createState() => _DashboardMapPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
|
||||||
|
static const AppLatLng _defaultFallbackCenter = AppLatLng(37.7749, -122.4194);
|
||||||
|
AppLatLng? _lastKnownRiderPos;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Ensure follow mode is active whenever the Touring map panel is mounted.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
ref.read(mapFollowStateProvider.notifier).recenter();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isValidCoord(double? lat, double? lng) {
|
||||||
|
return lat != null &&
|
||||||
|
lng != null &&
|
||||||
|
lat.isFinite &&
|
||||||
|
lng.isFinite &&
|
||||||
|
(lat != 0.0 || lng != 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Re-enable camera follow automatically when a ride session starts.
|
||||||
|
ref.listen<RideSession>(rideStateMachineProvider, (previous, next) {
|
||||||
|
if (previous?.state != RideState.riding &&
|
||||||
|
next.state == RideState.riding &&
|
||||||
|
mounted) {
|
||||||
|
ref.read(mapFollowStateProvider.notifier).recenter();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
final vehicleCoords = ref.watch(
|
final vehicleCoords = ref.watch(
|
||||||
telemetryStreamProvider.select(
|
telemetryStreamProvider.select(
|
||||||
(s) => (
|
(s) => (
|
||||||
|
|
@ -34,20 +73,22 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0)
|
final hasCoordinates = _isValidCoord(vehicleCoords.lat, vehicleCoords.lng);
|
||||||
? vehicleCoords.lat!
|
if (hasCoordinates) {
|
||||||
: 37.7749;
|
_lastKnownRiderPos = AppLatLng(vehicleCoords.lat!, vehicleCoords.lng!);
|
||||||
final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0)
|
}
|
||||||
? vehicleCoords.lng!
|
|
||||||
: -122.4194;
|
|
||||||
|
|
||||||
|
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
|
||||||
final isFresh = vehicleCoords.isGpsFresh;
|
final isFresh = vehicleCoords.isGpsFresh;
|
||||||
final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0;
|
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||||
|
final orientation = ref.watch(mapOrientationProvider);
|
||||||
|
final isNorthUp = orientation == MapOrientation.northUp;
|
||||||
|
final effectiveBearing = isNorthUp ? 0.0 : vehicleCoords.heading;
|
||||||
|
|
||||||
final vehicleMarker = AppMapMarker(
|
final vehicleMarker = AppMapMarker(
|
||||||
id: 'dash_vehicle',
|
id: 'dash_vehicle',
|
||||||
position: AppLatLng(centerLat, centerLng),
|
position: activeCenter,
|
||||||
heading: vehicleCoords.heading,
|
heading: isNorthUp ? vehicleCoords.heading : 0.0,
|
||||||
iconWidget: Icon(
|
iconWidget: Icon(
|
||||||
Icons.navigation,
|
Icons.navigation,
|
||||||
color: isFresh
|
color: isFresh
|
||||||
|
|
@ -64,7 +105,7 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
for (final alert in activeAlerts) {
|
for (final alert in activeAlerts) {
|
||||||
mapMarkers.add(
|
mapMarkers.add(
|
||||||
AppMapMarker(
|
AppMapMarker(
|
||||||
id: 'dash_alert__',
|
id: 'dash_alert_${alert.id}',
|
||||||
position: AppLatLng(alert.latitude, alert.longitude),
|
position: AppLatLng(alert.latitude, alert.longitude),
|
||||||
heading: alert.heading ?? 0.0,
|
heading: alert.heading ?? 0.0,
|
||||||
label: alert.category.displayName,
|
label: alert.category.displayName,
|
||||||
|
|
@ -116,14 +157,69 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: UnifiedMapView(
|
child: UnifiedMapView(
|
||||||
key: const ValueKey('dash_map_x'),
|
key: const ValueKey('dash_map_x'),
|
||||||
center: AppLatLng(centerLat, centerLng),
|
center: activeCenter,
|
||||||
zoom: zoom,
|
zoom: widget.zoom,
|
||||||
bearing: 0.0,
|
bearing: effectiveBearing,
|
||||||
markers: mapMarkers,
|
markers: mapMarkers,
|
||||||
polylines: polylines,
|
polylines: polylines,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!isFresh && hasCoordinates)
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.black.withValues(alpha: 0.8),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
key: const ValueKey('dashboard_map_orientation_button'),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: () {
|
||||||
|
ref.read(mapOrientationProvider.notifier).toggle();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isNorthUp
|
||||||
|
? Colors.white24
|
||||||
|
: AppColors.cyberCyan,
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isNorthUp ? Icons.explore : Icons.navigation,
|
||||||
|
size: 14,
|
||||||
|
color: isNorthUp
|
||||||
|
? Colors.white70
|
||||||
|
: AppColors.cyberCyan,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
isNorthUp ? 'NORTH UP' : 'HEADING UP',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
color: isNorthUp
|
||||||
|
? Colors.white70
|
||||||
|
: AppColors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null))
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 10,
|
left: 10,
|
||||||
|
|
@ -154,6 +250,56 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!isFollowMode)
|
||||||
|
Positioned(
|
||||||
|
bottom: 10,
|
||||||
|
right: 10,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.black.withValues(alpha: 0.8),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
key: const ValueKey('dashboard_map_recenter_button'),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: () {
|
||||||
|
ref.read(mapFollowStateProvider.notifier).recenter();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.cyberCyan,
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.my_location,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.cyberCyan,
|
||||||
|
),
|
||||||
|
SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
'RECENTER',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
color: AppColors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -162,3 +308,4 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,13 +168,17 @@ Future<void> showProUpgradeSheet(
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
// A default modal sheet is capped at 9/16 of the screen height, which in landscape
|
||||||
|
// (~230 dp on a phone) is shorter than this content. Allow the full height and make
|
||||||
|
// the content scrollable so it can never overflow (Issue #52 / BUG-005).
|
||||||
|
isScrollControlled: true,
|
||||||
backgroundColor: const Color(0xFF0F0F0F),
|
backgroundColor: const Color(0xFF0F0F0F),
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
bool _showCornering = true;
|
bool _showCornering = true;
|
||||||
|
|
||||||
double _currentZoom = 17.0;
|
double _currentZoom = 17.0;
|
||||||
bool _isNorthUp = true;
|
|
||||||
String _destinationQuery = '';
|
String _destinationQuery = '';
|
||||||
late final TextEditingController _searchController;
|
late final TextEditingController _searchController;
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
|
|
@ -907,6 +906,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
|
|
||||||
final isFollowMode = ref.watch(mapFollowStateProvider);
|
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||||
final layerSettings = ref.watch(mapLayerSettingsProvider);
|
final layerSettings = ref.watch(mapLayerSettingsProvider);
|
||||||
|
final isNorthUp = ref.watch(mapOrientationProvider) == MapOrientation.northUp;
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
|
@ -1084,7 +1084,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
final motorcycleMarker = AppMapMarker(
|
final motorcycleMarker = AppMapMarker(
|
||||||
id: 'motorcycle_live',
|
id: 'motorcycle_live',
|
||||||
position: currentPos,
|
position: currentPos,
|
||||||
heading: _isNorthUp ? currentHeading : 0.0,
|
heading: isNorthUp ? currentHeading : 0.0,
|
||||||
iconWidget: Icon(
|
iconWidget: Icon(
|
||||||
Icons.navigation,
|
Icons.navigation,
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
|
|
@ -1189,7 +1189,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
key: const ValueKey('live_map_view'),
|
key: const ValueKey('live_map_view'),
|
||||||
center: currentPos,
|
center: currentPos,
|
||||||
zoom: _currentZoom,
|
zoom: _currentZoom,
|
||||||
bearing: _isNorthUp ? 0.0 : currentHeading,
|
bearing: isNorthUp ? 0.0 : currentHeading,
|
||||||
markers: mapMarkers,
|
markers: mapMarkers,
|
||||||
polylines: livePolylines,
|
polylines: livePolylines,
|
||||||
),
|
),
|
||||||
|
|
@ -1280,10 +1280,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_HudIconButton(
|
_HudIconButton(
|
||||||
key: const ValueKey('compass_orient_button'),
|
key: const ValueKey('compass_orient_button'),
|
||||||
icon: _isNorthUp ? Icons.explore : Icons.navigation,
|
icon: isNorthUp ? Icons.explore : Icons.navigation,
|
||||||
tooltip: _isNorthUp ? 'North Up' : 'Heading Up',
|
tooltip: isNorthUp ? 'North Up' : 'Heading Up',
|
||||||
color: _isNorthUp ? Colors.white70 : colors.cyberCyan,
|
color: isNorthUp ? Colors.white70 : colors.cyberCyan,
|
||||||
onTap: () => setState(() => _isNorthUp = !_isNorthUp),
|
onTap: () => ref.read(mapOrientationProvider.notifier).toggle(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_HudIconButton(
|
_HudIconButton(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -100,6 +100,14 @@ class _FeedCommentsBottomSheetState
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
||||||
|
final secondaryTextColor =
|
||||||
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
||||||
|
final accentColor =
|
||||||
|
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
|
||||||
|
final sendBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
|
||||||
|
final sendFg = isLight ? AppColors.onBrand : const Color(0xFF00363D);
|
||||||
|
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
final feedItems = ref.watch(socialFeedNotifierProvider);
|
final feedItems = ref.watch(socialFeedNotifierProvider);
|
||||||
final livePost = feedItems.cast<SocialFeedItem?>().firstWhere(
|
final livePost = feedItems.cast<SocialFeedItem?>().firstWhere(
|
||||||
|
|
@ -116,14 +124,14 @@ class _FeedCommentsBottomSheetState
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF101419),
|
color: colors.surfaceCardPrimary,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
border: Border.all(color: colors.surfaceBorder),
|
||||||
boxShadow: const [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black87,
|
color: colors.glowShadow,
|
||||||
blurRadius: 30,
|
blurRadius: 30,
|
||||||
offset: Offset(0, -10),
|
offset: const Offset(0, -10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -139,7 +147,7 @@ class _FeedCommentsBottomSheetState
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.outlineVariant,
|
color: colors.surfaceBorder,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -149,9 +157,9 @@ class _FeedCommentsBottomSheetState
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
|
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.chat_bubble_outline_rounded,
|
Icons.chat_bubble_outline_rounded,
|
||||||
color: AppColors.electricCyan,
|
color: accentColor,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -161,7 +169,7 @@ class _FeedCommentsBottomSheetState
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.white,
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -169,33 +177,35 @@ class _FeedCommentsBottomSheetState
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8, vertical: 2),
|
horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1D2025),
|
color: colors.surfaceCardSecondary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.electricCyan.withValues(alpha: 0.3),
|
color: accentColor.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${comments.length}',
|
'${comments.length}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.electricCyan,
|
color: accentColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close_rounded,
|
icon: Icon(
|
||||||
color: Color(0xFF849396)),
|
Icons.close_rounded,
|
||||||
|
color: secondaryTextColor,
|
||||||
|
),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
tooltip: 'Close',
|
tooltip: 'Close',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(color: Color(0xFF1D2025), height: 1),
|
Divider(color: colors.surfaceBorder, height: 1),
|
||||||
|
|
||||||
// Comment List or Empty
|
// Comment List or Empty
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
@ -209,16 +219,16 @@ class _FeedCommentsBottomSheetState
|
||||||
Icon(
|
Icon(
|
||||||
Icons.forum_outlined,
|
Icons.forum_outlined,
|
||||||
size: 40,
|
size: 40,
|
||||||
color: colors.outline.withValues(alpha: 0.4),
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
Text(
|
||||||
'No debrief comments yet',
|
'No debrief comments yet',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white70,
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
@ -228,7 +238,7 @@ class _FeedCommentsBottomSheetState
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.outline,
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -246,10 +256,10 @@ class _FeedCommentsBottomSheetState
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF181C21),
|
color: colors.surfaceCardSecondary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFF272A30),
|
color: colors.surfaceBorder,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -259,12 +269,11 @@ class _FeedCommentsBottomSheetState
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 14,
|
radius: 14,
|
||||||
backgroundColor:
|
backgroundColor: colors.surfaceCardPrimary,
|
||||||
const Color(0xFF272A30),
|
child: Icon(
|
||||||
child: const Icon(
|
|
||||||
Icons.sports_motorsports_rounded,
|
Icons.sports_motorsports_rounded,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.electricCyan,
|
color: accentColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -275,12 +284,12 @@ class _FeedCommentsBottomSheetState
|
||||||
child: Text(
|
child: Text(
|
||||||
c.authorDisplayName,
|
c.authorDisplayName,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily:
|
fontFamily:
|
||||||
AppTypography.fontHeadline,
|
AppTypography.fontHeadline,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -292,7 +301,7 @@ class _FeedCommentsBottomSheetState
|
||||||
fontFamily:
|
fontFamily:
|
||||||
AppTypography.fontBody,
|
AppTypography.fontBody,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: colors.outline,
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -304,7 +313,7 @@ class _FeedCommentsBottomSheetState
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: colors.outline,
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -314,11 +323,11 @@ class _FeedCommentsBottomSheetState
|
||||||
padding: const EdgeInsets.only(left: 36),
|
padding: const EdgeInsets.only(left: 36),
|
||||||
child: Text(
|
child: Text(
|
||||||
c.content,
|
c.content,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
height: 1.35,
|
height: 1.35,
|
||||||
color: Color(0xFFE0E2EA),
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -329,7 +338,7 @@ class _FeedCommentsBottomSheetState
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const Divider(color: Color(0xFF1D2025), height: 1),
|
Divider(color: colors.surfaceBorder, height: 1),
|
||||||
|
|
||||||
// Comment Input Field (Glove Friendly)
|
// Comment Input Field (Glove Friendly)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -340,28 +349,28 @@ class _FeedCommentsBottomSheetState
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF181C21),
|
color: colors.surfaceCardSecondary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFF272A30),
|
color: colors.surfaceBorder,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _commentController,
|
controller: _commentController,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Colors.white,
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Add comment or pace tip...',
|
hintText: 'Add comment or pace tip...',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF849396),
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 14,
|
horizontal: 14,
|
||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
|
|
@ -376,8 +385,8 @@ class _FeedCommentsBottomSheetState
|
||||||
width: 48,
|
width: 48,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.electricCyan,
|
backgroundColor: sendBg,
|
||||||
foregroundColor: const Color(0xFF00363D),
|
foregroundColor: sendFg,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -387,12 +396,12 @@ class _FeedCommentsBottomSheetState
|
||||||
onPressed:
|
onPressed:
|
||||||
_isSubmitting ? null : () => _handlePostComment(),
|
_isSubmitting ? null : () => _handlePostComment(),
|
||||||
child: _isSubmitting
|
child: _isSubmitting
|
||||||
? const SizedBox(
|
? SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Color(0xFF00363D),
|
color: sendFg,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.send_rounded, size: 20),
|
: const Icon(Icons.send_rounded, size: 20),
|
||||||
|
|
|
||||||
|
|
@ -58,19 +58,24 @@ class _FeedTelemetryInspectorModalState
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
||||||
|
final secondaryTextColor =
|
||||||
|
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
|
||||||
|
final accentColor =
|
||||||
|
isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
|
||||||
final item = widget.feedItem;
|
final item = widget.feedItem;
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final maxHeight = mediaQuery.size.height * 0.90;
|
final maxHeight = mediaQuery.size.height * 0.90;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF0F1318),
|
color: colors.surfaceCardPrimary,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(color: Color(0xFF222933), width: 1.5),
|
top: BorderSide(color: colors.surfaceBorder, width: 1.5),
|
||||||
left: BorderSide(color: Color(0xFF222933), width: 1.5),
|
left: BorderSide(color: colors.surfaceBorder, width: 1.5),
|
||||||
right: BorderSide(color: Color(0xFF222933), width: 1.5),
|
right: BorderSide(color: colors.surfaceBorder, width: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
|
|
@ -86,7 +91,7 @@ class _FeedTelemetryInspectorModalState
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF333E4D),
|
color: colors.surfaceBorder,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -100,15 +105,15 @@ class _FeedTelemetryInspectorModalState
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.electricCyan.withValues(alpha: 0.15),
|
color: accentColor.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.electricCyan.withValues(alpha: 0.35),
|
color: accentColor.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.insights_rounded,
|
Icons.insights_rounded,
|
||||||
color: AppColors.electricCyan,
|
color: accentColor,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -123,11 +128,11 @@ class _FeedTelemetryInspectorModalState
|
||||||
child: Text(
|
child: Text(
|
||||||
item.authorDisplayName,
|
item.authorDisplayName,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.white,
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -139,19 +144,27 @@ class _FeedTelemetryInspectorModalState
|
||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.hazardAmber.withValues(alpha: 0.2),
|
color: (isLight
|
||||||
|
? AppColors.semanticWarningLight
|
||||||
|
: AppColors.hazardAmber)
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.hazardAmber.withValues(alpha: 0.4),
|
color: (isLight
|
||||||
|
? AppColors.semanticWarningLight
|
||||||
|
: AppColors.hazardAmber)
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
item.authorBadge!,
|
item.authorBadge!,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.hazardAmber,
|
color: isLight
|
||||||
|
? AppColors.semanticWarningLight
|
||||||
|
: AppColors.hazardAmber,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -161,24 +174,24 @@ class _FeedTelemetryInspectorModalState
|
||||||
Text(
|
Text(
|
||||||
'${item.rideTitle} • ${item.bikeModel}',
|
'${item.rideTitle} • ${item.bikeModel}',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF8B98A5),
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close_rounded, color: Colors.white70),
|
icon: Icon(Icons.close_rounded, color: secondaryTextColor),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const Divider(color: Color(0xFF1D242E), height: 1),
|
Divider(color: colors.surfaceBorder, height: 1),
|
||||||
|
|
||||||
// Scrollable Content: Metrics + Graph + Replay Trigger
|
// Scrollable Content: Metrics + Graph + Replay Trigger
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
@ -188,11 +201,11 @@ class _FeedTelemetryInspectorModalState
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// 2x3 Telemetry Insets Grid
|
// 2x3 Telemetry Insets Grid
|
||||||
_buildMetricsGrid(item, colors),
|
_buildMetricsGrid(item, colors, isLight, secondaryTextColor, accentColor),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
// Trace Selector Tabs
|
// Trace Selector Tabs
|
||||||
_buildTraceSelector(colors),
|
_buildTraceSelector(colors, secondaryTextColor, accentColor),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Interactive Telemetry Graph Canvas
|
// Interactive Telemetry Graph Canvas
|
||||||
|
|
@ -200,11 +213,11 @@ class _FeedTelemetryInspectorModalState
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Scrubber Details Pill
|
// Scrubber Details Pill
|
||||||
_buildScrubberReadout(item, colors),
|
_buildScrubberReadout(item, colors, secondaryTextColor, accentColor),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Launch Full Replay Button
|
// Launch Full Replay Button
|
||||||
_buildReplayButton(context),
|
_buildReplayButton(context, colors, accentColor),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -215,13 +228,24 @@ class _FeedTelemetryInspectorModalState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetricsGrid(SocialFeedItem item, MotorSignalThemeColors colors) {
|
Widget _buildMetricsGrid(
|
||||||
|
SocialFeedItem item,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
bool isLight,
|
||||||
|
Color secondaryTextColor,
|
||||||
|
Color accentColor,
|
||||||
|
) {
|
||||||
|
final successColor =
|
||||||
|
isLight ? AppColors.semanticSuccessLight : const Color(0xFF00E676);
|
||||||
|
final warnColor =
|
||||||
|
isLight ? AppColors.semanticWarningLight : AppColors.hazardAmber;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF141A22),
|
color: colors.surfaceCardSecondary,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFF222B38)),
|
border: Border.all(color: colors.surfaceBorder),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -232,34 +256,37 @@ class _FeedTelemetryInspectorModalState
|
||||||
label: 'PEAK LEAN',
|
label: 'PEAK LEAN',
|
||||||
value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°',
|
value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°',
|
||||||
subtext: '${item.maxLeanSide} APEX',
|
subtext: '${item.maxLeanSide} APEX',
|
||||||
accentColor: AppColors.electricCyan,
|
accentColor: accentColor,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
icon: Icons.rotate_right_rounded,
|
icon: Icons.rotate_right_rounded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
Container(width: 1, height: 48, color: colors.surfaceBorder),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildMetricCell(
|
child: _buildMetricCell(
|
||||||
label: 'DISTANCE',
|
label: 'DISTANCE',
|
||||||
value: item.distanceMiles.toStringAsFixed(1),
|
value: item.distanceMiles.toStringAsFixed(1),
|
||||||
subtext: 'MILES',
|
subtext: 'MILES',
|
||||||
accentColor: Colors.white,
|
accentColor: colors.textPrimary,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
icon: Icons.route_rounded,
|
icon: Icons.route_rounded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
Container(width: 1, height: 48, color: colors.surfaceBorder),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildMetricCell(
|
child: _buildMetricCell(
|
||||||
label: 'DURATION',
|
label: 'DURATION',
|
||||||
value: _formatDuration(item.durationSeconds),
|
value: _formatDuration(item.durationSeconds),
|
||||||
subtext: 'MOVING',
|
subtext: 'MOVING',
|
||||||
accentColor: Colors.white,
|
accentColor: colors.textPrimary,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
icon: Icons.timer_outlined,
|
icon: Icons.timer_outlined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Divider(color: Color(0xFF222B38), height: 1),
|
Divider(color: colors.surfaceBorder, height: 1),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -270,29 +297,30 @@ class _FeedTelemetryInspectorModalState
|
||||||
subtext: item.paceFactor.contains(' ')
|
subtext: item.paceFactor.contains(' ')
|
||||||
? item.paceFactor.split(' ').sublist(1).join(' ')
|
? item.paceFactor.split(' ').sublist(1).join(' ')
|
||||||
: 'PACE',
|
: 'PACE',
|
||||||
accentColor: const Color(0xFF00E676),
|
accentColor: successColor,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
icon: Icons.speed_rounded,
|
icon: Icons.speed_rounded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
Container(width: 1, height: 48, color: colors.surfaceBorder),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildMetricCell(
|
child: _buildMetricCell(
|
||||||
label: 'ELEVATION',
|
label: 'ELEVATION',
|
||||||
value: '+${item.elevationFt}',
|
value: '+${item.elevationFt}',
|
||||||
subtext: 'FEET GAIN',
|
subtext: 'FEET GAIN',
|
||||||
accentColor: Colors.white,
|
accentColor: colors.textPrimary,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
icon: Icons.terrain_rounded,
|
icon: Icons.terrain_rounded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, height: 48, color: const Color(0xFF242E3D)),
|
Container(width: 1, height: 48, color: colors.surfaceBorder),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildMetricCell(
|
child: _buildMetricCell(
|
||||||
label: 'HAZARDS',
|
label: 'HAZARDS',
|
||||||
value: '${item.hazardCount}',
|
value: '${item.hazardCount}',
|
||||||
subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED',
|
subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED',
|
||||||
accentColor: item.hazardCount == 0
|
accentColor: item.hazardCount == 0 ? successColor : warnColor,
|
||||||
? const Color(0xFF00E676)
|
secondaryTextColor: secondaryTextColor,
|
||||||
: AppColors.hazardAmber,
|
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -308,6 +336,7 @@ class _FeedTelemetryInspectorModalState
|
||||||
required String value,
|
required String value,
|
||||||
required String subtext,
|
required String subtext,
|
||||||
required Color accentColor,
|
required Color accentColor,
|
||||||
|
required Color secondaryTextColor,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|
@ -317,18 +346,18 @@ class _FeedTelemetryInspectorModalState
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 12, color: const Color(0xFF6B7A8D)),
|
Icon(icon, size: 12, color: secondaryTextColor),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 8.5,
|
fontSize: 8.5,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
color: Color(0xFF7B8B9E),
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -350,12 +379,12 @@ class _FeedTelemetryInspectorModalState
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
subtext,
|
subtext,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 8.5,
|
fontSize: 8.5,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
color: Color(0xFF5D6C7E),
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -363,13 +392,17 @@ class _FeedTelemetryInspectorModalState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTraceSelector(MotorSignalThemeColors colors) {
|
Widget _buildTraceSelector(
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
Color secondaryTextColor,
|
||||||
|
Color accentColor,
|
||||||
|
) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF141920),
|
color: colors.surfaceCardSecondary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0xFF222B38)),
|
border: Border.all(color: colors.surfaceBorder),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -377,16 +410,25 @@ class _FeedTelemetryInspectorModalState
|
||||||
tab: _InspectorTraceTab.leanApex,
|
tab: _InspectorTraceTab.leanApex,
|
||||||
label: 'LEAN & APEX',
|
label: 'LEAN & APEX',
|
||||||
icon: Icons.rotate_left_rounded,
|
icon: Icons.rotate_left_rounded,
|
||||||
|
colors: colors,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
|
accentColor: accentColor,
|
||||||
),
|
),
|
||||||
_buildTraceTabItem(
|
_buildTraceTabItem(
|
||||||
tab: _InspectorTraceTab.speedGForce,
|
tab: _InspectorTraceTab.speedGForce,
|
||||||
label: 'SPEED & G-FORCE',
|
label: 'SPEED & G-FORCE',
|
||||||
icon: Icons.show_chart_rounded,
|
icon: Icons.show_chart_rounded,
|
||||||
|
colors: colors,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
|
accentColor: accentColor,
|
||||||
),
|
),
|
||||||
_buildTraceTabItem(
|
_buildTraceTabItem(
|
||||||
tab: _InspectorTraceTab.elevation,
|
tab: _InspectorTraceTab.elevation,
|
||||||
label: 'ELEVATION',
|
label: 'ELEVATION',
|
||||||
icon: Icons.terrain_rounded,
|
icon: Icons.terrain_rounded,
|
||||||
|
colors: colors,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
|
accentColor: accentColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -397,6 +439,9 @@ class _FeedTelemetryInspectorModalState
|
||||||
required _InspectorTraceTab tab,
|
required _InspectorTraceTab tab,
|
||||||
required String label,
|
required String label,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
required MotorSignalThemeColors colors,
|
||||||
|
required Color secondaryTextColor,
|
||||||
|
required Color accentColor,
|
||||||
}) {
|
}) {
|
||||||
final isSelected = _selectedTab == tab;
|
final isSelected = _selectedTab == tab;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
|
|
@ -407,10 +452,10 @@ class _FeedTelemetryInspectorModalState
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? const Color(0xFF202A36) : Colors.transparent,
|
color: isSelected ? colors.surfaceCardPrimary : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: isSelected
|
border: isSelected
|
||||||
? Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4))
|
? Border.all(color: accentColor.withValues(alpha: 0.4))
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -419,7 +464,7 @@ class _FeedTelemetryInspectorModalState
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 13,
|
size: 13,
|
||||||
color: isSelected ? AppColors.electricCyan : const Color(0xFF6B7A8D),
|
color: isSelected ? accentColor : secondaryTextColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 5),
|
const SizedBox(width: 5),
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
@ -431,7 +476,7 @@ class _FeedTelemetryInspectorModalState
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
color: isSelected ? Colors.white : const Color(0xFF7A8B9E),
|
color: isSelected ? colors.textPrimary : secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -446,6 +491,7 @@ class _FeedTelemetryInspectorModalState
|
||||||
SocialFeedItem item,
|
SocialFeedItem item,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
) {
|
) {
|
||||||
|
final hasRecorded = item.hasRecordedTelemetry;
|
||||||
return Container(
|
return Container(
|
||||||
height: 220,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -455,33 +501,92 @@ class _FeedTelemetryInspectorModalState
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: GestureDetector(
|
child: Stack(
|
||||||
onHorizontalDragUpdate: (details) {
|
fit: StackFit.expand,
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
children: [
|
||||||
if (box != null) {
|
LayoutBuilder(
|
||||||
final localX = details.localPosition.dx;
|
builder: (context, graphConstraints) {
|
||||||
setState(() {
|
final graphWidth =
|
||||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
graphConstraints.maxWidth > 0 ? graphConstraints.maxWidth : 340.0;
|
||||||
});
|
return GestureDetector(
|
||||||
}
|
onHorizontalDragUpdate: hasRecorded
|
||||||
},
|
? (details) {
|
||||||
onTapDown: (details) {
|
setState(() {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
_scrubFraction =
|
||||||
if (box != null) {
|
(details.localPosition.dx / graphWidth)
|
||||||
final localX = details.localPosition.dx;
|
.clamp(0.0, 1.0);
|
||||||
setState(() {
|
});
|
||||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
}
|
||||||
});
|
: null,
|
||||||
}
|
onTapDown: hasRecorded
|
||||||
},
|
? (details) {
|
||||||
child: CustomPaint(
|
setState(() {
|
||||||
size: const Size(double.infinity, 220),
|
_scrubFraction =
|
||||||
painter: _TelemetryWaveformPainter(
|
(details.localPosition.dx / graphWidth)
|
||||||
tab: _selectedTab,
|
.clamp(0.0, 1.0);
|
||||||
item: item,
|
});
|
||||||
scrubFraction: _scrubFraction,
|
}
|
||||||
|
: 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -490,19 +595,22 @@ class _FeedTelemetryInspectorModalState
|
||||||
Widget _buildScrubberReadout(
|
Widget _buildScrubberReadout(
|
||||||
SocialFeedItem item,
|
SocialFeedItem item,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
|
Color secondaryTextColor,
|
||||||
|
Color accentColor,
|
||||||
) {
|
) {
|
||||||
|
final hasRecorded = item.hasRecordedTelemetry;
|
||||||
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
|
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
|
||||||
final scrubPct = (_scrubFraction * 100).toInt();
|
final scrubPct = (_scrubFraction * 100).toInt();
|
||||||
|
|
||||||
// Instantaneous interpolated readings based on scrub point
|
// Instantaneous interpolated readings from recorded telemetry samples
|
||||||
final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction);
|
final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF141920),
|
color: colors.surfaceCardSecondary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0xFF222B38)),
|
border: Border.all(color: colors.surfaceBorder),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -512,22 +620,24 @@ class _FeedTelemetryInspectorModalState
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.touch_app_rounded,
|
Icons.touch_app_rounded,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: AppColors.electricCyan,
|
color: accentColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
'POINT: $scrubDist MI ($scrubPct%)',
|
hasRecorded
|
||||||
|
? 'POINT: $scrubDist MI ($scrubPct%)'
|
||||||
|
: 'TELEMETRY TRACE UNAVAILABLE',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 9.5,
|
fontSize: 9.5,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
color: AppColors.electricCyan,
|
color: accentColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -535,13 +645,13 @@ class _FeedTelemetryInspectorModalState
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
Text(
|
||||||
'DRAG TO SCRUB',
|
hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 8.5,
|
fontSize: 8.5,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Color(0xFF5D6C7E),
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -550,11 +660,11 @@ class _FeedTelemetryInspectorModalState
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
_buildScrubStat('SPEED', '$speedVal MPH'),
|
_buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH', colors, secondaryTextColor),
|
||||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
Container(width: 1, height: 24, color: colors.surfaceBorder),
|
||||||
_buildScrubStat('LEAN', '$leanVal°'),
|
_buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°', colors, secondaryTextColor),
|
||||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
Container(width: 1, height: 24, color: colors.surfaceBorder),
|
||||||
_buildScrubStat('LAT G', '${gVal}G'),
|
_buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G', colors, secondaryTextColor),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -563,72 +673,99 @@ class _FeedTelemetryInspectorModalState
|
||||||
}
|
}
|
||||||
|
|
||||||
(int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) {
|
(int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) {
|
||||||
// Generate realistic wave curves based on session metrics
|
if (!item.hasRecordedTelemetry || item.telemetrySamples.isEmpty) {
|
||||||
final baseSpeed = 48.0;
|
return (0, 0, '0.00');
|
||||||
final speedVariation = 26.0 * math.sin(frac * math.pi * 5);
|
}
|
||||||
final speed = (baseSpeed + speedVariation).clamp(20, 115).toInt();
|
|
||||||
|
|
||||||
final leanVariation = item.maxLeanAngleDeg * math.sin(frac * math.pi * 7).abs();
|
final samples = item.telemetrySamples;
|
||||||
final lean = leanVariation.clamp(0, item.maxLeanAngleDeg).toInt();
|
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 aLean = FeedTelemetrySample.applyMovingLeanGate(
|
||||||
|
speedMph: a.speedMph,
|
||||||
|
leanAngleDeg: a.leanAngleDeg,
|
||||||
|
).abs();
|
||||||
|
final bLean = FeedTelemetrySample.applyMovingLeanGate(
|
||||||
|
speedMph: b.speedMph,
|
||||||
|
leanAngleDeg: b.leanAngleDeg,
|
||||||
|
).abs();
|
||||||
|
|
||||||
|
final speed = (a.speedMph + (b.speedMph - a.speedMph) * t).round();
|
||||||
|
final lean = (aLean + (bLean - aLean) * t).round();
|
||||||
|
final latG = (a.lateralG.abs() + (b.lateralG.abs() - a.lateralG.abs()) * t)
|
||||||
|
.toStringAsFixed(2);
|
||||||
|
|
||||||
|
return (speed, lean, latG);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScrubStat(String label, String value) {
|
Widget _buildScrubStat(
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
Color secondaryTextColor,
|
||||||
|
) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 8.5,
|
fontSize: 8.5,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Color(0xFF6B7A8D),
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: Colors.white,
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReplayButton(BuildContext context) {
|
Widget _buildReplayButton(
|
||||||
|
BuildContext context,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
Color accentColor,
|
||||||
|
) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF161E28),
|
backgroundColor: colors.surfaceCardSecondary,
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: AppColors.electricCyan.withValues(alpha: 0.5),
|
color: accentColor.withValues(alpha: 0.5),
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.play_circle_fill_rounded,
|
Icons.play_circle_fill_rounded,
|
||||||
color: AppColors.electricCyan,
|
color: accentColor,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
label: const Text(
|
label: Text(
|
||||||
'OPEN FULL RIDE REPLAY',
|
'OPEN FULL RIDE REPLAY',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
color: Colors.white,
|
color: colors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -640,7 +777,8 @@ class _FeedTelemetryInspectorModalState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom painter rendering multi-trace telemetry waveforms and apex markers.
|
/// Custom painter rendering recorded multi-trace telemetry waveforms and apex markers.
|
||||||
|
/// Never fabricates synthetic curves when recorded telemetry samples are absent.
|
||||||
class _TelemetryWaveformPainter extends CustomPainter {
|
class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
final _InspectorTraceTab tab;
|
final _InspectorTraceTab tab;
|
||||||
final SocialFeedItem item;
|
final SocialFeedItem item;
|
||||||
|
|
@ -671,6 +809,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
|
canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!item.hasRecordedTelemetry || item.telemetrySamples.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _InspectorTraceTab.leanApex:
|
case _InspectorTraceTab.leanApex:
|
||||||
_paintLeanWaveform(canvas, size);
|
_paintLeanWaveform(canvas, size);
|
||||||
|
|
@ -701,6 +843,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
final w = size.width;
|
final w = size.width;
|
||||||
final h = size.height;
|
final h = size.height;
|
||||||
final midY = h * 0.52;
|
final midY = h * 0.52;
|
||||||
|
final samples = item.telemetrySamples;
|
||||||
|
|
||||||
// Baseline (0° upright)
|
// Baseline (0° upright)
|
||||||
final baselinePaint = Paint()
|
final baselinePaint = Paint()
|
||||||
|
|
@ -710,24 +853,30 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
|
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
final points = <Offset>[];
|
var peakIndex = 0;
|
||||||
const steps = 100;
|
var peakAbsLean = 0.0;
|
||||||
final maxLean = item.maxLeanAngleDeg;
|
var peakPoint = Offset(0, midY);
|
||||||
|
|
||||||
for (var i = 0; i <= steps; i++) {
|
for (var i = 0; i < samples.length; i++) {
|
||||||
final t = i / steps;
|
final s = samples[i];
|
||||||
|
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||||
final x = t * w;
|
final x = t * w;
|
||||||
// Multi-sine wave generating cornering sections
|
final leanAngle = FeedTelemetrySample.applyMovingLeanGate(
|
||||||
final wave = math.sin(t * math.pi * 6) * 0.7 +
|
speedMph: s.speedMph,
|
||||||
math.sin(t * math.pi * 12) * 0.3;
|
leanAngleDeg: s.leanAngleDeg,
|
||||||
final leanAngle = wave * maxLean;
|
);
|
||||||
final y = midY - (leanAngle / 60.0) * (h * 0.38);
|
final y = midY - (leanAngle / 65.0) * (h * 0.38);
|
||||||
points.add(Offset(x, y));
|
final pt = Offset(x, y);
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
path.moveTo(x, y);
|
path.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
path.lineTo(x, y);
|
path.lineTo(x, y);
|
||||||
}
|
}
|
||||||
|
if (leanAngle.abs() >= peakAbsLean) {
|
||||||
|
peakAbsLean = leanAngle.abs();
|
||||||
|
peakIndex = i;
|
||||||
|
peakPoint = pt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Gradient Area under Lean Curve
|
// Draw Gradient Area under Lean Curve
|
||||||
|
|
@ -754,49 +903,59 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
canvas.drawPath(path, linePaint);
|
canvas.drawPath(path, linePaint);
|
||||||
|
|
||||||
// Draw Apex Markers (Peak Corners)
|
if (peakIndex >= 0 && peakAbsLean > 0) {
|
||||||
final apexMarkers = [
|
const markerColor = Color(0xFF00E676);
|
||||||
(0.26, '${item.maxLeanAngleDeg.toStringAsFixed(0)}° APEX', const Color(0xFF00E676)),
|
final dotPaint = Paint()..color = markerColor;
|
||||||
(0.68, '${(item.maxLeanAngleDeg * 0.94).toStringAsFixed(0)}° SWEEP', const Color(0xFFFFB300)),
|
canvas.drawCircle(peakPoint, 5.0, dotPaint);
|
||||||
];
|
|
||||||
|
|
||||||
for (final marker in apexMarkers) {
|
|
||||||
final mx = marker.$1 * w;
|
|
||||||
final wave = math.sin(marker.$1 * math.pi * 6) * 0.7 +
|
|
||||||
math.sin(marker.$1 * math.pi * 12) * 0.3;
|
|
||||||
final my = midY - (wave * maxLean / 60.0) * (h * 0.38);
|
|
||||||
|
|
||||||
final dotPaint = Paint()..color = marker.$3;
|
|
||||||
canvas.drawCircle(Offset(mx, my), 5.0, dotPaint);
|
|
||||||
|
|
||||||
final ringPaint = Paint()
|
final ringPaint = Paint()
|
||||||
..color = marker.$3.withValues(alpha: 0.4)
|
..color = markerColor.withValues(alpha: 0.4)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 3;
|
..strokeWidth = 3;
|
||||||
canvas.drawCircle(Offset(mx, my), 8.0, ringPaint);
|
canvas.drawCircle(peakPoint, 8.0, ringPaint);
|
||||||
|
|
||||||
// Label Pill
|
_drawTextBadge(
|
||||||
_drawTextBadge(canvas, marker.$2, Offset(mx - 24, my - 24), marker.$3);
|
canvas,
|
||||||
|
'${peakAbsLean.toStringAsFixed(0)}° APEX',
|
||||||
|
Offset(
|
||||||
|
(peakPoint.dx - 24).clamp(6.0, w - 70.0),
|
||||||
|
(peakPoint.dy - 24).clamp(6.0, h - 24.0),
|
||||||
|
),
|
||||||
|
markerColor,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _paintSpeedWaveform(Canvas canvas, Size size) {
|
void _paintSpeedWaveform(Canvas canvas, Size size) {
|
||||||
final w = size.width;
|
final w = size.width;
|
||||||
final h = size.height;
|
final h = size.height;
|
||||||
|
final samples = item.telemetrySamples;
|
||||||
|
|
||||||
|
var maxSpeed = 1.0;
|
||||||
|
for (final s in samples) {
|
||||||
|
if (s.speedMph > maxSpeed) maxSpeed = s.speedMph;
|
||||||
|
}
|
||||||
|
final speedScale = math.max(maxSpeed * 1.15, 30.0);
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
const steps = 100;
|
var peakSpeed = 0.0;
|
||||||
|
var peakPoint = Offset(w * 0.5, h * 0.5);
|
||||||
|
|
||||||
for (var i = 0; i <= steps; i++) {
|
for (var i = 0; i < samples.length; i++) {
|
||||||
final t = i / steps;
|
final s = samples[i];
|
||||||
|
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||||
final x = t * w;
|
final x = t * w;
|
||||||
final speed = 40.0 + 35.0 * math.sin(t * math.pi * 4).abs() + 15.0 * math.cos(t * math.pi * 8);
|
final y = (h * 0.90 - (s.speedMph / speedScale) * (h * 0.75))
|
||||||
final y = h * 0.90 - (speed / 120.0) * (h * 0.75);
|
.clamp(h * 0.1, h * 0.90);
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
path.moveTo(x, y);
|
path.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
path.lineTo(x, y);
|
path.lineTo(x, y);
|
||||||
}
|
}
|
||||||
|
if (s.speedMph >= peakSpeed) {
|
||||||
|
peakSpeed = s.speedMph;
|
||||||
|
peakPoint = Offset(x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final fillPath = Path.from(path)
|
final fillPath = Path.from(path)
|
||||||
|
|
@ -821,26 +980,49 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
canvas.drawPath(path, linePaint);
|
canvas.drawPath(path, linePaint);
|
||||||
|
|
||||||
_drawTextBadge(canvas, 'TOP SPEED', Offset(w * 0.5 - 28, h * 0.16), const Color(0xFFFFB300));
|
_drawTextBadge(
|
||||||
|
canvas,
|
||||||
|
'${peakSpeed.round()} MPH TOP SPEED',
|
||||||
|
Offset(
|
||||||
|
(peakPoint.dx - 36).clamp(6.0, w - 100.0),
|
||||||
|
(peakPoint.dy - 22).clamp(6.0, h - 24.0),
|
||||||
|
),
|
||||||
|
const Color(0xFFFFB300),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _paintElevationWaveform(Canvas canvas, Size size) {
|
void _paintElevationWaveform(Canvas canvas, Size size) {
|
||||||
final w = size.width;
|
final w = size.width;
|
||||||
final h = size.height;
|
final h = size.height;
|
||||||
|
final samples = item.telemetrySamples;
|
||||||
|
|
||||||
|
var minAlt = samples.first.altitudeFt;
|
||||||
|
var maxAlt = samples.first.altitudeFt;
|
||||||
|
for (final s in samples) {
|
||||||
|
if (s.altitudeFt < minAlt) minAlt = s.altitudeFt;
|
||||||
|
if (s.altitudeFt > maxAlt) maxAlt = s.altitudeFt;
|
||||||
|
}
|
||||||
|
final altSpan = (maxAlt - minAlt).abs();
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
const steps = 100;
|
var crestPoint = Offset(w * 0.5, h * 0.25);
|
||||||
|
var highestAlt = minAlt;
|
||||||
|
|
||||||
for (var i = 0; i <= steps; i++) {
|
for (var i = 0; i < samples.length; i++) {
|
||||||
final t = i / steps;
|
final s = samples[i];
|
||||||
|
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||||
final x = t * w;
|
final x = t * w;
|
||||||
final elevationNorm = 0.2 + 0.65 * (math.sin(t * math.pi * 2.2) * 0.5 + 0.5);
|
final norm = altSpan < 1.0 ? 0.5 : (s.altitudeFt - minAlt) / altSpan;
|
||||||
final y = h * 0.92 - elevationNorm * (h * 0.75);
|
final y = h * 0.92 - (0.15 + norm * 0.70) * (h * 0.75);
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
path.moveTo(x, y);
|
path.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
path.lineTo(x, y);
|
path.lineTo(x, y);
|
||||||
}
|
}
|
||||||
|
if (s.altitudeFt >= highestAlt) {
|
||||||
|
highestAlt = s.altitudeFt;
|
||||||
|
crestPoint = Offset(x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final fillPath = Path.from(path)
|
final fillPath = Path.from(path)
|
||||||
|
|
@ -868,7 +1050,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
_drawTextBadge(
|
_drawTextBadge(
|
||||||
canvas,
|
canvas,
|
||||||
'+${item.elevationFt} FT CREST',
|
'+${item.elevationFt} FT CREST',
|
||||||
Offset(w * 0.68 - 36, h * 0.18),
|
Offset(
|
||||||
|
(crestPoint.dx - 36).clamp(6.0, w - 95.0),
|
||||||
|
(crestPoint.dy - 22).clamp(6.0, h - 24.0),
|
||||||
|
),
|
||||||
const Color(0xFF448AFF),
|
const Color(0xFF448AFF),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,6 +4,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart' as ll;
|
import 'package:latlong2/latlong.dart' as ll;
|
||||||
import '../routes/aws_location_route_provider.dart';
|
import '../routes/aws_location_route_provider.dart';
|
||||||
import 'map_provider_interface.dart';
|
import 'map_provider_interface.dart';
|
||||||
|
import 'map_providers.dart';
|
||||||
|
|
||||||
/// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite).
|
/// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite).
|
||||||
class AwsMapProvider implements AppMapProvider {
|
class AwsMapProvider implements AppMapProvider {
|
||||||
|
|
@ -98,8 +99,10 @@ class _AwsMapView extends StatefulWidget {
|
||||||
State<_AwsMapView> createState() => _AwsMapViewState();
|
State<_AwsMapView> createState() => _AwsMapViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AwsMapViewState extends State<_AwsMapView> {
|
class _AwsMapViewState extends State<_AwsMapView>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
late final MapController _mapController;
|
late final MapController _mapController;
|
||||||
|
AnimationController? _animController;
|
||||||
ll.LatLng? _lastFollowPos;
|
ll.LatLng? _lastFollowPos;
|
||||||
DateTime? _lastFollowTime;
|
DateTime? _lastFollowTime;
|
||||||
|
|
||||||
|
|
@ -107,6 +110,56 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_mapController = MapController();
|
_mapController = MapController();
|
||||||
|
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateCameraTo({
|
||||||
|
required ll.LatLng target,
|
||||||
|
required double targetZoom,
|
||||||
|
required double targetBearing,
|
||||||
|
required Duration duration,
|
||||||
|
Curve curve = Curves.easeOutCubic,
|
||||||
|
}) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_animController?.stop();
|
||||||
|
_animController?.dispose();
|
||||||
|
_animController = null;
|
||||||
|
|
||||||
|
final startCenter = _mapController.camera.center;
|
||||||
|
final startZoom = _mapController.camera.zoom;
|
||||||
|
final startBearing = _mapController.camera.rotation;
|
||||||
|
|
||||||
|
final bearingDiff = shortestAngleDiff(startBearing, targetBearing);
|
||||||
|
final endBearing = startBearing + bearingDiff;
|
||||||
|
|
||||||
|
_animController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
final curved = CurvedAnimation(parent: _animController!, curve: curve);
|
||||||
|
|
||||||
|
final startLat = startCenter.latitude;
|
||||||
|
final startLng = startCenter.longitude;
|
||||||
|
final endLat = target.latitude;
|
||||||
|
final endLng = target.longitude;
|
||||||
|
|
||||||
|
_animController!.addListener(() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final t = curved.value;
|
||||||
|
final lat = startLat + (endLat - startLat) * t;
|
||||||
|
final lng = startLng + (endLng - startLng) * t;
|
||||||
|
final z = startZoom + (targetZoom - startZoom) * t;
|
||||||
|
final b = startBearing + (endBearing - startBearing) * t;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_mapController.move(ll.LatLng(lat, lng), z);
|
||||||
|
_mapController.rotate(normalizeAngle(b));
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
_animController!.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -124,48 +177,56 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
||||||
? 1.0
|
? 1.0
|
||||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||||
|
final hasMoved = posDelta > 0.000005;
|
||||||
|
|
||||||
final shouldMove = justBecameFollowMode ||
|
final intervalMs = _lastFollowTime == null
|
||||||
_lastFollowPos == null ||
|
? 250
|
||||||
zoomChanged ||
|
: now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
|
||||||
(posDelta > 0.00005 &&
|
|
||||||
(_lastFollowTime == null ||
|
|
||||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
|
||||||
|
|
||||||
if (shouldMove) {
|
final isInitialOrLargeJump =
|
||||||
|
justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
|
||||||
|
|
||||||
|
if (isInitialOrLargeJump) {
|
||||||
|
_animController?.stop();
|
||||||
_lastFollowPos = centerLl;
|
_lastFollowPos = centerLl;
|
||||||
_lastFollowTime = now;
|
_lastFollowTime = now;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
try {
|
||||||
if (mounted) {
|
_mapController.move(centerLl, widget.zoom);
|
||||||
try {
|
_mapController.rotate(normalizeAngle(widget.bearing));
|
||||||
_mapController.move(centerLl, widget.zoom);
|
} catch (_) {}
|
||||||
} catch (_) {}
|
} else if (hasMoved || bearingChanged || zoomChanged) {
|
||||||
}
|
_lastFollowPos = centerLl;
|
||||||
});
|
_lastFollowTime = now;
|
||||||
|
_animateCameraTo(
|
||||||
|
target: centerLl,
|
||||||
|
targetZoom: widget.zoom,
|
||||||
|
targetBearing: widget.bearing,
|
||||||
|
duration: Duration(milliseconds: intervalMs),
|
||||||
|
curve: Curves.linear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (bearingChanged) {
|
||||||
|
_animateCameraTo(
|
||||||
|
target: _mapController.camera.center,
|
||||||
|
targetZoom: widget.zoom,
|
||||||
|
targetBearing: widget.bearing,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
} else if (zoomChanged) {
|
||||||
|
try {
|
||||||
|
_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) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
try {
|
|
||||||
_mapController.rotate(widget.bearing);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_animController?.stop();
|
||||||
|
_animController?.dispose();
|
||||||
|
_animController = null;
|
||||||
_mapController.dispose();
|
_mapController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps;
|
import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps;
|
||||||
import '../../core/theme/map_style.dart';
|
import '../../core/theme/map_style.dart';
|
||||||
import 'map_provider_interface.dart';
|
import 'map_provider_interface.dart';
|
||||||
|
import 'map_providers.dart';
|
||||||
|
|
||||||
class GoogleMapProvider implements AppMapProvider {
|
class GoogleMapProvider implements AppMapProvider {
|
||||||
gmaps.GoogleMapController? _controller;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppMapType get type => AppMapType.googleMaps;
|
AppMapType get type => AppMapType.googleMaps;
|
||||||
|
|
||||||
|
|
@ -25,15 +24,122 @@ class GoogleMapProvider implements AppMapProvider {
|
||||||
required ValueChanged<AppLatLng>? onPositionChanged,
|
required ValueChanged<AppLatLng>? onPositionChanged,
|
||||||
required VoidCallback? onUserInteraction,
|
required VoidCallback? onUserInteraction,
|
||||||
}) {
|
}) {
|
||||||
final target = gmaps.LatLng(center.latitude, center.longitude);
|
return _GoogleMapView(
|
||||||
|
center: center,
|
||||||
|
zoom: zoom,
|
||||||
|
bearing: bearing,
|
||||||
|
markers: markers,
|
||||||
|
polylines: polylines,
|
||||||
|
isFollowMode: isFollowMode,
|
||||||
|
onPositionChanged: onPositionChanged,
|
||||||
|
onUserInteraction: onUserInteraction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isFollowMode && _controller != null) {
|
class _GoogleMapView extends StatefulWidget {
|
||||||
_controller!.animateCamera(
|
final AppLatLng center;
|
||||||
gmaps.CameraUpdate.newLatLngZoom(target, zoom),
|
final double zoom;
|
||||||
|
final double bearing;
|
||||||
|
final List<AppMapMarker> markers;
|
||||||
|
final List<AppPolyline> polylines;
|
||||||
|
final bool isFollowMode;
|
||||||
|
final ValueChanged<AppLatLng>? onPositionChanged;
|
||||||
|
final VoidCallback? onUserInteraction;
|
||||||
|
|
||||||
|
const _GoogleMapView({
|
||||||
|
required this.center,
|
||||||
|
required this.zoom,
|
||||||
|
this.bearing = 0.0,
|
||||||
|
required this.markers,
|
||||||
|
required this.polylines,
|
||||||
|
required this.isFollowMode,
|
||||||
|
required this.onPositionChanged,
|
||||||
|
required this.onUserInteraction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_GoogleMapView> createState() => _GoogleMapViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GoogleMapViewState extends State<_GoogleMapView> {
|
||||||
|
gmaps.GoogleMapController? _controller;
|
||||||
|
gmaps.LatLng? _lastFollowPos;
|
||||||
|
DateTime? _lastFollowTime;
|
||||||
|
bool _isProgrammaticMove = false;
|
||||||
|
|
||||||
|
static const int _followThrottleMs = 250;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_lastFollowPos = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _moveCamera(gmaps.LatLng target, double zoom, double bearing) async {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null) return;
|
||||||
|
_lastFollowPos = target;
|
||||||
|
_lastFollowTime = DateTime.now();
|
||||||
|
_isProgrammaticMove = true;
|
||||||
|
try {
|
||||||
|
await controller.animateCamera(
|
||||||
|
gmaps.CameraUpdate.newCameraPosition(
|
||||||
|
gmaps.CameraPosition(
|
||||||
|
target: target,
|
||||||
|
zoom: zoom,
|
||||||
|
bearing: normalizeAngle(bearing),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
_isProgrammaticMove = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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(
|
return gmaps.Marker(
|
||||||
markerId: gmaps.MarkerId(m.id),
|
markerId: gmaps.MarkerId(m.id),
|
||||||
position: gmaps.LatLng(m.position.latitude, m.position.longitude),
|
position: gmaps.LatLng(m.position.latitude, m.position.longitude),
|
||||||
|
|
@ -42,7 +148,7 @@ class GoogleMapProvider implements AppMapProvider {
|
||||||
);
|
);
|
||||||
}).toSet();
|
}).toSet();
|
||||||
|
|
||||||
final gPolylines = polylines.map((p) {
|
final gPolylines = widget.polylines.map((p) {
|
||||||
return gmaps.Polyline(
|
return gmaps.Polyline(
|
||||||
polylineId: gmaps.PolylineId(p.id),
|
polylineId: gmaps.PolylineId(p.id),
|
||||||
points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(),
|
points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(),
|
||||||
|
|
@ -58,7 +164,8 @@ class GoogleMapProvider implements AppMapProvider {
|
||||||
key: ValueKey('google_map_$sizeKey'),
|
key: ValueKey('google_map_$sizeKey'),
|
||||||
initialCameraPosition: gmaps.CameraPosition(
|
initialCameraPosition: gmaps.CameraPosition(
|
||||||
target: target,
|
target: target,
|
||||||
zoom: zoom,
|
zoom: widget.zoom,
|
||||||
|
bearing: widget.bearing,
|
||||||
),
|
),
|
||||||
markers: gMarkers,
|
markers: gMarkers,
|
||||||
polylines: gPolylines,
|
polylines: gPolylines,
|
||||||
|
|
@ -68,13 +175,21 @@ class GoogleMapProvider implements AppMapProvider {
|
||||||
compassEnabled: false,
|
compassEnabled: false,
|
||||||
onMapCreated: (controller) {
|
onMapCreated: (controller) {
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
|
if (widget.isFollowMode) {
|
||||||
|
_moveCamera(target, widget.zoom, widget.bearing);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCameraMoveStarted: () {
|
onCameraMoveStarted: () {
|
||||||
onUserInteraction?.call();
|
if (!_isProgrammaticMove) {
|
||||||
|
widget.onUserInteraction?.call();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCameraMove: (position) {
|
onCameraMove: (position) {
|
||||||
onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude));
|
widget.onPositionChanged?.call(
|
||||||
|
AppLatLng(position.target.latitude, position.target.longitude),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,3 +94,69 @@ final mapFollowStateProvider =
|
||||||
StateNotifierProvider<MapFollowNotifier, bool>((ref) {
|
StateNotifierProvider<MapFollowNotifier, bool>((ref) {
|
||||||
return MapFollowNotifier();
|
return MapFollowNotifier();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Map orientation mode: North Up vs Heading Up.
|
||||||
|
enum MapOrientation {
|
||||||
|
northUp,
|
||||||
|
headingUp;
|
||||||
|
|
||||||
|
String get displayName => this == northUp ? 'North Up' : 'Heading Up';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the shortest angular difference in degrees from [from] to [to].
|
||||||
|
/// Result is in range (-180.0, 180.0].
|
||||||
|
/// Handles circular transitions across the 0°/360° discontinuity:
|
||||||
|
/// e.g. from 359° to 1° yields +2°, not -358°.
|
||||||
|
double shortestAngleDiff(double from, double to) {
|
||||||
|
return ((to - from + 540.0) % 360.0) - 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes an angle in degrees into [0.0, 360.0).
|
||||||
|
double normalizeAngle(double angle) {
|
||||||
|
return (angle % 360.0 + 360.0) % 360.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapOrientationNotifier extends StateNotifier<MapOrientation> {
|
||||||
|
static const String _prefKey = 'selected_map_orientation';
|
||||||
|
|
||||||
|
MapOrientationNotifier() : super(MapOrientation.northUp) {
|
||||||
|
_loadFromPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFromPrefs() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final savedVal = prefs.getString(_prefKey);
|
||||||
|
if (savedVal == 'headingUp') {
|
||||||
|
state = MapOrientation.headingUp;
|
||||||
|
} else if (savedVal == 'northUp') {
|
||||||
|
state = MapOrientation.northUp;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggle() async {
|
||||||
|
final next = state == MapOrientation.northUp
|
||||||
|
? MapOrientation.headingUp
|
||||||
|
: MapOrientation.northUp;
|
||||||
|
state = next;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_prefKey, next.name);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setOrientation(MapOrientation orientation) async {
|
||||||
|
state = orientation;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_prefKey, orientation.name);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mapOrientationProvider =
|
||||||
|
StateNotifierProvider<MapOrientationNotifier, MapOrientation>((ref) {
|
||||||
|
return MapOrientationNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart' as ll;
|
import 'package:latlong2/latlong.dart' as ll;
|
||||||
import 'map_provider_interface.dart';
|
import 'map_provider_interface.dart';
|
||||||
|
import 'map_providers.dart';
|
||||||
|
|
||||||
class OpenStreetMapProvider implements AppMapProvider {
|
class OpenStreetMapProvider implements AppMapProvider {
|
||||||
@override
|
@override
|
||||||
|
|
@ -65,8 +66,10 @@ class _OsmMapView extends StatefulWidget {
|
||||||
State<_OsmMapView> createState() => _OsmMapViewState();
|
State<_OsmMapView> createState() => _OsmMapViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OsmMapViewState extends State<_OsmMapView> {
|
class _OsmMapViewState extends State<_OsmMapView>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
late final MapController _mapController;
|
late final MapController _mapController;
|
||||||
|
AnimationController? _animController;
|
||||||
ll.LatLng? _lastFollowPos;
|
ll.LatLng? _lastFollowPos;
|
||||||
DateTime? _lastFollowTime;
|
DateTime? _lastFollowTime;
|
||||||
|
|
||||||
|
|
@ -74,6 +77,56 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_mapController = MapController();
|
_mapController = MapController();
|
||||||
|
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateCameraTo({
|
||||||
|
required ll.LatLng target,
|
||||||
|
required double targetZoom,
|
||||||
|
required double targetBearing,
|
||||||
|
required Duration duration,
|
||||||
|
Curve curve = Curves.easeOutCubic,
|
||||||
|
}) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_animController?.stop();
|
||||||
|
_animController?.dispose();
|
||||||
|
_animController = null;
|
||||||
|
|
||||||
|
final startCenter = _mapController.camera.center;
|
||||||
|
final startZoom = _mapController.camera.zoom;
|
||||||
|
final startBearing = _mapController.camera.rotation;
|
||||||
|
|
||||||
|
final bearingDiff = shortestAngleDiff(startBearing, targetBearing);
|
||||||
|
final endBearing = startBearing + bearingDiff;
|
||||||
|
|
||||||
|
_animController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
final curved = CurvedAnimation(parent: _animController!, curve: curve);
|
||||||
|
|
||||||
|
final startLat = startCenter.latitude;
|
||||||
|
final startLng = startCenter.longitude;
|
||||||
|
final endLat = target.latitude;
|
||||||
|
final endLng = target.longitude;
|
||||||
|
|
||||||
|
_animController!.addListener(() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final t = curved.value;
|
||||||
|
final lat = startLat + (endLat - startLat) * t;
|
||||||
|
final lng = startLng + (endLng - startLng) * t;
|
||||||
|
final z = startZoom + (targetZoom - startZoom) * t;
|
||||||
|
final b = startBearing + (endBearing - startBearing) * t;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_mapController.move(ll.LatLng(lat, lng), z);
|
||||||
|
_mapController.rotate(normalizeAngle(b));
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
_animController!.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -90,48 +143,56 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
||||||
? 1.0
|
? 1.0
|
||||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||||
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
||||||
|
final hasMoved = posDelta > 0.000005;
|
||||||
|
|
||||||
final shouldMove = justBecameFollowMode ||
|
final intervalMs = _lastFollowTime == null
|
||||||
_lastFollowPos == null ||
|
? 250
|
||||||
zoomChanged ||
|
: now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
|
||||||
(posDelta > 0.00005 &&
|
|
||||||
(_lastFollowTime == null ||
|
|
||||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
|
||||||
|
|
||||||
if (shouldMove) {
|
final isInitialOrLargeJump =
|
||||||
|
justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
|
||||||
|
|
||||||
|
if (isInitialOrLargeJump) {
|
||||||
|
_animController?.stop();
|
||||||
_lastFollowPos = centerLl;
|
_lastFollowPos = centerLl;
|
||||||
_lastFollowTime = now;
|
_lastFollowTime = now;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
try {
|
||||||
if (mounted) {
|
_mapController.move(centerLl, widget.zoom);
|
||||||
try {
|
_mapController.rotate(normalizeAngle(widget.bearing));
|
||||||
_mapController.move(centerLl, widget.zoom);
|
} catch (_) {}
|
||||||
} catch (_) {}
|
} else if (hasMoved || bearingChanged || zoomChanged) {
|
||||||
}
|
_lastFollowPos = centerLl;
|
||||||
});
|
_lastFollowTime = now;
|
||||||
|
_animateCameraTo(
|
||||||
|
target: centerLl,
|
||||||
|
targetZoom: widget.zoom,
|
||||||
|
targetBearing: widget.bearing,
|
||||||
|
duration: Duration(milliseconds: intervalMs),
|
||||||
|
curve: Curves.linear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (bearingChanged) {
|
||||||
|
_animateCameraTo(
|
||||||
|
target: _mapController.camera.center,
|
||||||
|
targetZoom: widget.zoom,
|
||||||
|
targetBearing: widget.bearing,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
} else if (zoomChanged) {
|
||||||
|
try {
|
||||||
|
_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) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
try {
|
|
||||||
_mapController.rotate(widget.bearing);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_animController?.stop();
|
||||||
|
_animController?.dispose();
|
||||||
|
_animController = null;
|
||||||
_mapController.dispose();
|
_mapController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,66 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Privacy-safe coarse reverse geocode for a geographic point.
|
||||||
|
///
|
||||||
|
/// Returns a coarse region label (e.g. "Saratoga, CA") suitable for privacy-safe
|
||||||
|
/// starting location labels without exposing house numbers, street names, or exact GPS coordinates.
|
||||||
|
///
|
||||||
|
/// Returns null if unavailable or if reverse-geocoding fails.
|
||||||
|
Future<String?> reverseGeocodeCoarse(AppLatLng position) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'https://places.geo.$region.amazonaws.com/v2/reverse-geocode?key=$apiKey',
|
||||||
|
);
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'QueryPosition': [position.longitude, position.latitude],
|
||||||
|
'Filter': {
|
||||||
|
'IncludeCountries': ['USA'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 6));
|
||||||
|
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final items = data['ResultItems'] as List<dynamic>? ?? [];
|
||||||
|
if (items.isEmpty) return null;
|
||||||
|
|
||||||
|
final first = items.first as Map<String, dynamic>;
|
||||||
|
final address = first['Address'] as Map<String, dynamic>?;
|
||||||
|
if (address == null) return null;
|
||||||
|
|
||||||
|
// Extract coarse locality and region/state — strictly avoid street addresses or exact house numbers
|
||||||
|
final locality = address['Locality']?.toString().trim();
|
||||||
|
final regionStr = address['Region']?.toString().trim();
|
||||||
|
final subRegion = address['SubRegion']?.toString().trim();
|
||||||
|
|
||||||
|
if (locality != null &&
|
||||||
|
locality.isNotEmpty &&
|
||||||
|
regionStr != null &&
|
||||||
|
regionStr.isNotEmpty) {
|
||||||
|
return '$locality, $regionStr';
|
||||||
|
} else if (locality != null && locality.isNotEmpty) {
|
||||||
|
return locality;
|
||||||
|
} else if (subRegion != null &&
|
||||||
|
subRegion.isNotEmpty &&
|
||||||
|
regionStr != null &&
|
||||||
|
regionStr.isNotEmpty) {
|
||||||
|
return '$subRegion, $regionStr';
|
||||||
|
} else if (regionStr != null && regionStr.isNotEmpty) {
|
||||||
|
return regionStr;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RouteCalculationResult> calculateRoute(
|
Future<RouteCalculationResult> calculateRoute(
|
||||||
List<RouteStop> stops, {
|
List<RouteStop> stops, {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../map/map_provider_interface.dart';
|
import '../map/map_provider_interface.dart';
|
||||||
|
|
||||||
DateTime? _parseDateTime(dynamic value) {
|
DateTime? _parseDateTime(dynamic value) {
|
||||||
|
|
@ -134,10 +135,103 @@ enum FeedSnapshotType {
|
||||||
scenicPhoto,
|
scenicPhoto,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A recorded telemetry sample point attached to a shared social feed session.
|
||||||
|
@immutable
|
||||||
|
class FeedTelemetrySample {
|
||||||
|
/// Matches RideStateMachine._processStats 3.0 MPH moving gate and 65.0°
|
||||||
|
/// physical bound so stationary kickstand/dismount frames never enter
|
||||||
|
/// published lean traces or apex calculations.
|
||||||
|
static const double movingLeanGateSpeedMph = 3.0;
|
||||||
|
static const double maxPhysicalLeanDeg = 65.0;
|
||||||
|
|
||||||
|
static double applyMovingLeanGate({
|
||||||
|
required double? speedMph,
|
||||||
|
required double? leanAngleDeg,
|
||||||
|
}) {
|
||||||
|
if (speedMph == null ||
|
||||||
|
!speedMph.isFinite ||
|
||||||
|
speedMph < movingLeanGateSpeedMph ||
|
||||||
|
leanAngleDeg == null ||
|
||||||
|
!leanAngleDeg.isFinite ||
|
||||||
|
leanAngleDeg.abs() > maxPhysicalLeanDeg) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
return leanAngleDeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
final double progress; // 0.0 to 1.0 along the session
|
||||||
|
final double speedMph;
|
||||||
|
final double leanAngleDeg; // signed degrees (-left, +right)
|
||||||
|
final double lateralG;
|
||||||
|
final double altitudeFt;
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
final DateTime? timestamp;
|
||||||
|
|
||||||
|
const FeedTelemetrySample({
|
||||||
|
required this.progress,
|
||||||
|
required this.speedMph,
|
||||||
|
required this.leanAngleDeg,
|
||||||
|
this.lateralG = 0.0,
|
||||||
|
this.altitudeFt = 0.0,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
FeedTelemetrySample withMovingLeanGate() {
|
||||||
|
final gatedLean = applyMovingLeanGate(
|
||||||
|
speedMph: speedMph,
|
||||||
|
leanAngleDeg: leanAngleDeg,
|
||||||
|
);
|
||||||
|
if (gatedLean == leanAngleDeg) return this;
|
||||||
|
return FeedTelemetrySample(
|
||||||
|
progress: progress,
|
||||||
|
speedMph: speedMph,
|
||||||
|
leanAngleDeg: gatedLean,
|
||||||
|
lateralG: lateralG,
|
||||||
|
altitudeFt: altitudeFt,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
timestamp: timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'progress': progress,
|
||||||
|
'speedMph': speedMph,
|
||||||
|
'leanAngleDeg': leanAngleDeg,
|
||||||
|
'lateralG': lateralG,
|
||||||
|
'altitudeFt': altitudeFt,
|
||||||
|
if (latitude != null) 'lat': latitude,
|
||||||
|
if (longitude != null) 'lng': longitude,
|
||||||
|
if (timestamp != null) 'timestamp': _dateTimeToFirestore(timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory FeedTelemetrySample.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FeedTelemetrySample(
|
||||||
|
progress: ((json['progress'] as num?)?.toDouble() ?? 0.0).clamp(0.0, 1.0),
|
||||||
|
speedMph: (json['speedMph'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
leanAngleDeg: (json['leanAngleDeg'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
lateralG: (json['lateralG'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
altitudeFt: (json['altitudeFt'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
latitude: (json['lat'] as num?)?.toDouble(),
|
||||||
|
longitude: (json['lng'] as num?)?.toDouble(),
|
||||||
|
timestamp: _parseDateTime(json['timestamp']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A community social feed item (Stitch SOC-01).
|
/// A community social feed item (Stitch SOC-01).
|
||||||
@immutable
|
@immutable
|
||||||
class SocialFeedItem {
|
class SocialFeedItem {
|
||||||
|
/// Minimum geodesic displacement required for GPS verification: 0.05 miles (~80.47 meters).
|
||||||
|
static const double minimumVerifiedDisplacementMiles = 0.05;
|
||||||
|
static const double minimumVerifiedDisplacementMeters =
|
||||||
|
minimumVerifiedDisplacementMiles * 1609.344;
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
final int? rideId;
|
||||||
final String authorUid;
|
final String authorUid;
|
||||||
final String authorDisplayName;
|
final String authorDisplayName;
|
||||||
final String authorHandle;
|
final String authorHandle;
|
||||||
|
|
@ -153,7 +247,7 @@ class SocialFeedItem {
|
||||||
final double distanceMiles;
|
final double distanceMiles;
|
||||||
final int durationSeconds;
|
final int durationSeconds;
|
||||||
final double maxLeanAngleDeg;
|
final double maxLeanAngleDeg;
|
||||||
final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK'
|
final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK', 'NONE'
|
||||||
final String paceFactor; // e.g. '1.32x Spirited'
|
final String paceFactor; // e.g. '1.32x Spirited'
|
||||||
final int hazardCount; // e.g. 0
|
final int hazardCount; // e.g. 0
|
||||||
final int elevationFt; // e.g. +2140
|
final int elevationFt; // e.g. +2140
|
||||||
|
|
@ -163,9 +257,10 @@ class SocialFeedItem {
|
||||||
final String? regionName;
|
final String? regionName;
|
||||||
final bool isFeatured;
|
final bool isFeatured;
|
||||||
|
|
||||||
// Visual Snapshot Module
|
// Visual Snapshot & Recorded Telemetry Module
|
||||||
final FeedSnapshotType snapshotType;
|
final FeedSnapshotType snapshotType;
|
||||||
final List<AppLatLng> routePoints;
|
final List<AppLatLng> routePoints;
|
||||||
|
final List<FeedTelemetrySample> telemetrySamples;
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final String? photoCaption;
|
final String? photoCaption;
|
||||||
final bool isGpsVerified;
|
final bool isGpsVerified;
|
||||||
|
|
@ -178,6 +273,7 @@ class SocialFeedItem {
|
||||||
|
|
||||||
const SocialFeedItem({
|
const SocialFeedItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
this.rideId,
|
||||||
required this.authorUid,
|
required this.authorUid,
|
||||||
required this.authorDisplayName,
|
required this.authorDisplayName,
|
||||||
required this.authorHandle,
|
required this.authorHandle,
|
||||||
|
|
@ -194,21 +290,164 @@ class SocialFeedItem {
|
||||||
this.maxLeanSide = 'RIGHT',
|
this.maxLeanSide = 'RIGHT',
|
||||||
this.paceFactor = '1.2x Spirited',
|
this.paceFactor = '1.2x Spirited',
|
||||||
this.hazardCount = 0,
|
this.hazardCount = 0,
|
||||||
this.elevationFt = 1200,
|
this.elevationFt = 0,
|
||||||
this.discipline = RidingDiscipline.canyonSport,
|
this.discipline = RidingDiscipline.canyonSport,
|
||||||
this.regionName,
|
this.regionName,
|
||||||
this.isFeatured = false,
|
this.isFeatured = false,
|
||||||
this.snapshotType = FeedSnapshotType.routeTrack,
|
this.snapshotType = FeedSnapshotType.routeTrack,
|
||||||
this.routePoints = const [],
|
this.routePoints = const [],
|
||||||
|
this.telemetrySamples = const [],
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.photoCaption,
|
this.photoCaption,
|
||||||
this.isGpsVerified = true,
|
this.isGpsVerified = false,
|
||||||
this.isDemo = false,
|
this.isDemo = false,
|
||||||
this.fistBumpCount = 0,
|
this.fistBumpCount = 0,
|
||||||
this.fistBumpUserIds = const [],
|
this.fistBumpUserIds = const [],
|
||||||
this.comments = const [],
|
this.comments = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Evaluates whether a set of ride metrics, GPS route coordinates, and
|
||||||
|
/// recorded telemetry samples satisfy authentic GPS verification criteria.
|
||||||
|
static bool verifyGpsCriteria({
|
||||||
|
required bool isDemo,
|
||||||
|
required double distanceMiles,
|
||||||
|
required int durationSeconds,
|
||||||
|
required List<AppLatLng> routePoints,
|
||||||
|
List<FeedTelemetrySample> telemetrySamples = const [],
|
||||||
|
}) {
|
||||||
|
if (isDemo) return false;
|
||||||
|
if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false;
|
||||||
|
if (durationSeconds <= 0) return false;
|
||||||
|
if (telemetrySamples.length < 2) return false;
|
||||||
|
|
||||||
|
double? firstProgress;
|
||||||
|
double? prevProgress;
|
||||||
|
DateTime? firstTimestamp;
|
||||||
|
DateTime? prevTimestamp;
|
||||||
|
int timestampCount = 0;
|
||||||
|
final validSamplePoints = <AppLatLng>[];
|
||||||
|
|
||||||
|
for (final sample in telemetrySamples) {
|
||||||
|
if (!sample.progress.isFinite ||
|
||||||
|
sample.progress < 0.0 ||
|
||||||
|
sample.progress > 1.0 ||
|
||||||
|
!sample.speedMph.isFinite ||
|
||||||
|
sample.speedMph < 0.0 ||
|
||||||
|
!sample.leanAngleDeg.isFinite ||
|
||||||
|
!sample.lateralG.isFinite ||
|
||||||
|
!sample.altitudeFt.isFinite) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prevProgress != null && sample.progress < prevProgress) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
firstProgress ??= sample.progress;
|
||||||
|
prevProgress = sample.progress;
|
||||||
|
|
||||||
|
if (sample.timestamp != null) {
|
||||||
|
if (prevTimestamp != null &&
|
||||||
|
sample.timestamp!.isBefore(prevTimestamp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
firstTimestamp ??= sample.timestamp;
|
||||||
|
prevTimestamp = sample.timestamp;
|
||||||
|
timestampCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lat = sample.latitude;
|
||||||
|
final lng = sample.longitude;
|
||||||
|
if (lat != null &&
|
||||||
|
lng != null &&
|
||||||
|
lat.isFinite &&
|
||||||
|
lng.isFinite &&
|
||||||
|
lat.abs() <= 90.0 &&
|
||||||
|
lng.abs() <= 180.0 &&
|
||||||
|
(lat != 0.0 || lng != 0.0)) {
|
||||||
|
validSamplePoints.add(AppLatLng(lat, lng));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require a positive recorded span across telemetry samples.
|
||||||
|
if (firstProgress == null ||
|
||||||
|
prevProgress == null ||
|
||||||
|
(prevProgress - firstProgress) <= 0.0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestampCount > 0) {
|
||||||
|
if (timestampCount < 2 ||
|
||||||
|
firstTimestamp == null ||
|
||||||
|
prevTimestamp == null ||
|
||||||
|
prevTimestamp.difference(firstTimestamp).inMilliseconds <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_hasMinimumGeodesicDisplacement(validSamplePoints)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final validPoints = routePoints.where((p) {
|
||||||
|
return p.latitude.isFinite &&
|
||||||
|
p.longitude.isFinite &&
|
||||||
|
p.latitude.abs() <= 90.0 &&
|
||||||
|
p.longitude.abs() <= 180.0 &&
|
||||||
|
(p.latitude != 0.0 || p.longitude != 0.0);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return _hasMinimumGeodesicDisplacement(validPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require at least two coordinates separated by >= 0.05 miles (~80.47 m)
|
||||||
|
/// measured geodesically via [Geolocator.distanceBetween].
|
||||||
|
static bool _hasMinimumGeodesicDisplacement(List<AppLatLng> points) {
|
||||||
|
if (points.length < 2) return false;
|
||||||
|
for (int i = 0; i < points.length - 1; i++) {
|
||||||
|
final origin = points[i];
|
||||||
|
for (int j = i + 1; j < points.length; j++) {
|
||||||
|
final target = points[j];
|
||||||
|
final distMeters = Geolocator.distanceBetween(
|
||||||
|
origin.latitude,
|
||||||
|
origin.longitude,
|
||||||
|
target.latitude,
|
||||||
|
target.longitude,
|
||||||
|
);
|
||||||
|
if (distMeters.isFinite &&
|
||||||
|
distMeters >= minimumVerifiedDisplacementMeters) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True only when this post is flagged as GPS-verified AND backed by valid,
|
||||||
|
/// non-demo GPS route coordinates, recorded telemetry samples, positive
|
||||||
|
/// distance, and positive duration.
|
||||||
|
bool get hasVerifiedGps =>
|
||||||
|
isGpsVerified &&
|
||||||
|
verifyGpsCriteria(
|
||||||
|
isDemo: isDemo,
|
||||||
|
distanceMiles: distanceMiles,
|
||||||
|
durationSeconds: durationSeconds,
|
||||||
|
routePoints: routePoints,
|
||||||
|
telemetrySamples: telemetrySamples,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// True when the post is a non-demo ride containing recorded route coordinates that can be plotted.
|
||||||
|
bool get hasRecordedRoute =>
|
||||||
|
!isDemo &&
|
||||||
|
routePoints
|
||||||
|
.where((p) =>
|
||||||
|
p.latitude.isFinite &&
|
||||||
|
p.longitude.isFinite &&
|
||||||
|
(p.latitude != 0.0 || p.longitude != 0.0))
|
||||||
|
.length >=
|
||||||
|
2;
|
||||||
|
|
||||||
|
/// True when the post contains recorded telemetry waveform samples.
|
||||||
|
bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2;
|
||||||
|
|
||||||
bool isFistBumpedBy(String? uid) {
|
bool isFistBumpedBy(String? uid) {
|
||||||
if (uid == null) return false;
|
if (uid == null) return false;
|
||||||
return fistBumpUserIds.contains(uid);
|
return fistBumpUserIds.contains(uid);
|
||||||
|
|
@ -216,6 +455,7 @@ class SocialFeedItem {
|
||||||
|
|
||||||
SocialFeedItem copyWith({
|
SocialFeedItem copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
|
int? rideId,
|
||||||
String? authorUid,
|
String? authorUid,
|
||||||
String? authorDisplayName,
|
String? authorDisplayName,
|
||||||
String? authorHandle,
|
String? authorHandle,
|
||||||
|
|
@ -238,6 +478,7 @@ class SocialFeedItem {
|
||||||
bool? isFeatured,
|
bool? isFeatured,
|
||||||
FeedSnapshotType? snapshotType,
|
FeedSnapshotType? snapshotType,
|
||||||
List<AppLatLng>? routePoints,
|
List<AppLatLng>? routePoints,
|
||||||
|
List<FeedTelemetrySample>? telemetrySamples,
|
||||||
String? photoUrl,
|
String? photoUrl,
|
||||||
String? photoCaption,
|
String? photoCaption,
|
||||||
bool? isGpsVerified,
|
bool? isGpsVerified,
|
||||||
|
|
@ -248,6 +489,7 @@ class SocialFeedItem {
|
||||||
}) {
|
}) {
|
||||||
return SocialFeedItem(
|
return SocialFeedItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
rideId: rideId ?? this.rideId,
|
||||||
authorUid: authorUid ?? this.authorUid,
|
authorUid: authorUid ?? this.authorUid,
|
||||||
authorDisplayName: authorDisplayName ?? this.authorDisplayName,
|
authorDisplayName: authorDisplayName ?? this.authorDisplayName,
|
||||||
authorHandle: authorHandle ?? this.authorHandle,
|
authorHandle: authorHandle ?? this.authorHandle,
|
||||||
|
|
@ -270,6 +512,7 @@ class SocialFeedItem {
|
||||||
isFeatured: isFeatured ?? this.isFeatured,
|
isFeatured: isFeatured ?? this.isFeatured,
|
||||||
snapshotType: snapshotType ?? this.snapshotType,
|
snapshotType: snapshotType ?? this.snapshotType,
|
||||||
routePoints: routePoints ?? this.routePoints,
|
routePoints: routePoints ?? this.routePoints,
|
||||||
|
telemetrySamples: telemetrySamples ?? this.telemetrySamples,
|
||||||
photoUrl: photoUrl ?? this.photoUrl,
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
photoCaption: photoCaption ?? this.photoCaption,
|
photoCaption: photoCaption ?? this.photoCaption,
|
||||||
isGpsVerified: isGpsVerified ?? this.isGpsVerified,
|
isGpsVerified: isGpsVerified ?? this.isGpsVerified,
|
||||||
|
|
@ -282,6 +525,7 @@ class SocialFeedItem {
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
|
if (rideId != null) 'rideId': rideId,
|
||||||
'authorUid': authorUid,
|
'authorUid': authorUid,
|
||||||
'authorDisplayName': authorDisplayName,
|
'authorDisplayName': authorDisplayName,
|
||||||
'authorHandle': authorHandle,
|
'authorHandle': authorHandle,
|
||||||
|
|
@ -303,10 +547,14 @@ class SocialFeedItem {
|
||||||
if (regionName != null) 'regionName': regionName,
|
if (regionName != null) 'regionName': regionName,
|
||||||
'isFeatured': isFeatured,
|
'isFeatured': isFeatured,
|
||||||
'snapshotType': snapshotType.name,
|
'snapshotType': snapshotType.name,
|
||||||
'routePoints': routePoints.map((p) => {'lat': p.latitude, 'lng': p.longitude}).toList(),
|
'routePoints': routePoints
|
||||||
|
.map((p) => {'lat': p.latitude, 'lng': p.longitude})
|
||||||
|
.toList(),
|
||||||
|
if (telemetrySamples.isNotEmpty)
|
||||||
|
'telemetrySamples': telemetrySamples.map((s) => s.toJson()).toList(),
|
||||||
if (photoUrl != null) 'photoUrl': photoUrl,
|
if (photoUrl != null) 'photoUrl': photoUrl,
|
||||||
if (photoCaption != null) 'photoCaption': photoCaption,
|
if (photoCaption != null) 'photoCaption': photoCaption,
|
||||||
'isGpsVerified': isGpsVerified,
|
'isGpsVerified': hasVerifiedGps,
|
||||||
'isDemo': isDemo,
|
'isDemo': isDemo,
|
||||||
'fistBumpCount': fistBumpCount,
|
'fistBumpCount': fistBumpCount,
|
||||||
'fistBumpUserIds': fistBumpUserIds,
|
'fistBumpUserIds': fistBumpUserIds,
|
||||||
|
|
@ -326,14 +574,35 @@ class SocialFeedItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? [];
|
||||||
|
final samples = telemetryList
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s))
|
||||||
|
.withMovingLeanGate())
|
||||||
|
.toList();
|
||||||
|
|
||||||
final commentList = json['comments'] as List<dynamic>? ?? [];
|
final commentList = json['comments'] as List<dynamic>? ?? [];
|
||||||
final comments = commentList
|
final comments = commentList
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map((c) => FeedPostComment.fromJson(c))
|
.map((c) => FeedPostComment.fromJson(c))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
final distanceMiles = (json['distanceMiles'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final durationSeconds = (json['durationSeconds'] as num?)?.toInt() ?? 0;
|
||||||
|
final isDemo = json['isDemo'] as bool? ?? false;
|
||||||
|
final rawVerified = json['isGpsVerified'] as bool? ?? false;
|
||||||
|
final verified = rawVerified &&
|
||||||
|
verifyGpsCriteria(
|
||||||
|
isDemo: isDemo,
|
||||||
|
distanceMiles: distanceMiles,
|
||||||
|
durationSeconds: durationSeconds,
|
||||||
|
routePoints: points,
|
||||||
|
telemetrySamples: samples,
|
||||||
|
);
|
||||||
|
|
||||||
return SocialFeedItem(
|
return SocialFeedItem(
|
||||||
id: json['id'] as String? ?? '',
|
id: json['id'] as String? ?? '',
|
||||||
|
rideId: (json['rideId'] as num?)?.toInt(),
|
||||||
authorUid: json['authorUid'] as String? ?? '',
|
authorUid: json['authorUid'] as String? ?? '',
|
||||||
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
|
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
|
||||||
authorHandle: json['authorHandle'] as String? ?? 'rider',
|
authorHandle: json['authorHandle'] as String? ?? 'rider',
|
||||||
|
|
@ -344,8 +613,8 @@ class SocialFeedItem {
|
||||||
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
|
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
|
||||||
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
|
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
|
||||||
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
|
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
|
||||||
distanceMiles: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0,
|
distanceMiles: distanceMiles,
|
||||||
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
|
durationSeconds: durationSeconds,
|
||||||
maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0,
|
maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0,
|
||||||
maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT',
|
maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT',
|
||||||
paceFactor: json['paceFactor'] as String? ?? '1.0x',
|
paceFactor: json['paceFactor'] as String? ?? '1.0x',
|
||||||
|
|
@ -363,13 +632,16 @@ class SocialFeedItem {
|
||||||
? FeedSnapshotType.scenicPhoto
|
? FeedSnapshotType.scenicPhoto
|
||||||
: FeedSnapshotType.routeTrack,
|
: FeedSnapshotType.routeTrack,
|
||||||
routePoints: points,
|
routePoints: points,
|
||||||
|
telemetrySamples: samples,
|
||||||
photoUrl: json['photoUrl'] as String?,
|
photoUrl: json['photoUrl'] as String?,
|
||||||
photoCaption: json['photoCaption'] as String?,
|
photoCaption: json['photoCaption'] as String?,
|
||||||
isGpsVerified: json['isGpsVerified'] as bool? ?? true,
|
isGpsVerified: verified,
|
||||||
isDemo: json['isDemo'] as bool? ?? false,
|
isDemo: isDemo,
|
||||||
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
|
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
|
||||||
fistBumpUserIds: (json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
|
fistBumpUserIds:
|
||||||
|
(json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||||
comments: comments,
|
comments: comments,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import '../../database/database.dart';
|
||||||
import '../map/map_provider_interface.dart';
|
import '../map/map_provider_interface.dart';
|
||||||
import 'social_feed_models.dart';
|
import 'social_feed_models.dart';
|
||||||
|
|
||||||
|
|
@ -363,7 +365,7 @@ class SocialFeedService {
|
||||||
result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList();
|
result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList();
|
||||||
}
|
}
|
||||||
if (verifiedGpsOnly == true) {
|
if (verifiedGpsOnly == true) {
|
||||||
result = result.where((i) => i.isGpsVerified).toList();
|
result = result.where((i) => i.hasVerifiedGps).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -433,6 +435,11 @@ class SocialFeedService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Share a recent completed ride session to the community feed.
|
/// Share a recent completed ride session to the community feed.
|
||||||
|
///
|
||||||
|
/// When [authoritativeRide] is provided, telemetry metrics (distance,
|
||||||
|
/// duration, max lean angle, lean side, elevation gain, and ride ID) are
|
||||||
|
/// derived strictly from [authoritativeRide] so manual/forged telemetry
|
||||||
|
/// overrides cannot be injected at the submission layer.
|
||||||
Future<SocialFeedItem> shareRecentRide({
|
Future<SocialFeedItem> shareRecentRide({
|
||||||
required String authorUid,
|
required String authorUid,
|
||||||
required String authorDisplayName,
|
required String authorDisplayName,
|
||||||
|
|
@ -443,21 +450,76 @@ class SocialFeedService {
|
||||||
required String locationName,
|
required String locationName,
|
||||||
required String rideTitle,
|
required String rideTitle,
|
||||||
required String bikeModel,
|
required String bikeModel,
|
||||||
required double distanceMiles,
|
double distanceMiles = 0.0,
|
||||||
required int durationSeconds,
|
int durationSeconds = 0,
|
||||||
required double maxLeanAngleDeg,
|
double maxLeanAngleDeg = 0.0,
|
||||||
String maxLeanSide = 'RIGHT',
|
String maxLeanSide = 'RIGHT',
|
||||||
String paceFactor = '1.2x Spirited',
|
String paceFactor = '1.0x Cruiser',
|
||||||
int hazardCount = 0,
|
int hazardCount = 0,
|
||||||
int elevationFt = 1500,
|
int elevationFt = 0,
|
||||||
RidingDiscipline discipline = RidingDiscipline.canyonSport,
|
RidingDiscipline discipline = RidingDiscipline.canyonSport,
|
||||||
String? regionName,
|
String? regionName,
|
||||||
bool isFeatured = false,
|
bool isFeatured = false,
|
||||||
FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack,
|
FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack,
|
||||||
List<AppLatLng> routePoints = const [],
|
List<AppLatLng> routePoints = const [],
|
||||||
|
List<FeedTelemetrySample> telemetrySamples = const [],
|
||||||
|
Ride? authoritativeRide,
|
||||||
|
int? rideId,
|
||||||
|
bool? isGpsVerified,
|
||||||
}) async {
|
}) async {
|
||||||
|
double resolvedDistance = distanceMiles;
|
||||||
|
int resolvedDurationSeconds = durationSeconds;
|
||||||
|
double resolvedMaxLean = maxLeanAngleDeg;
|
||||||
|
String resolvedLeanSide = maxLeanSide;
|
||||||
|
int resolvedElevationFt = elevationFt;
|
||||||
|
int? resolvedRideId = rideId;
|
||||||
|
|
||||||
|
if (authoritativeRide != null) {
|
||||||
|
resolvedRideId = authoritativeRide.id;
|
||||||
|
resolvedDistance = authoritativeRide.distanceMiles > 0
|
||||||
|
? authoritativeRide.distanceMiles
|
||||||
|
: authoritativeRide.distance;
|
||||||
|
resolvedDurationSeconds = authoritativeRide.movingTime > 0
|
||||||
|
? authoritativeRide.movingTime
|
||||||
|
: (authoritativeRide.endTime != null
|
||||||
|
? authoritativeRide.endTime!
|
||||||
|
.difference(authoritativeRide.startTime)
|
||||||
|
.inSeconds
|
||||||
|
.clamp(0, 864000)
|
||||||
|
: 0);
|
||||||
|
resolvedMaxLean = math.max(
|
||||||
|
authoritativeRide.maxLeanLeft,
|
||||||
|
authoritativeRide.maxLeanRight,
|
||||||
|
);
|
||||||
|
if (authoritativeRide.maxLeanLeft == 0 &&
|
||||||
|
authoritativeRide.maxLeanRight == 0) {
|
||||||
|
resolvedLeanSide = 'NONE';
|
||||||
|
} else {
|
||||||
|
resolvedLeanSide =
|
||||||
|
authoritativeRide.maxLeanRight >= authoritativeRide.maxLeanLeft
|
||||||
|
? 'RIGHT'
|
||||||
|
: 'LEFT';
|
||||||
|
}
|
||||||
|
resolvedElevationFt =
|
||||||
|
(authoritativeRide.elevationGain * 3.28084).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
final gatedTelemetrySamples = telemetrySamples
|
||||||
|
.map((s) => s.withMovingLeanGate())
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria(
|
||||||
|
isDemo: false,
|
||||||
|
distanceMiles: resolvedDistance,
|
||||||
|
durationSeconds: resolvedDurationSeconds,
|
||||||
|
routePoints: routePoints,
|
||||||
|
telemetrySamples: gatedTelemetrySamples,
|
||||||
|
);
|
||||||
|
final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification;
|
||||||
|
|
||||||
final newPost = SocialFeedItem(
|
final newPost = SocialFeedItem(
|
||||||
id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}',
|
id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
rideId: resolvedRideId,
|
||||||
authorUid: authorUid,
|
authorUid: authorUid,
|
||||||
authorDisplayName: authorDisplayName,
|
authorDisplayName: authorDisplayName,
|
||||||
authorHandle: authorHandle,
|
authorHandle: authorHandle,
|
||||||
|
|
@ -468,19 +530,21 @@ class SocialFeedService {
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
rideTitle: rideTitle,
|
rideTitle: rideTitle,
|
||||||
bikeModel: bikeModel,
|
bikeModel: bikeModel,
|
||||||
distanceMiles: distanceMiles,
|
distanceMiles: resolvedDistance,
|
||||||
durationSeconds: durationSeconds,
|
durationSeconds: resolvedDurationSeconds,
|
||||||
maxLeanAngleDeg: maxLeanAngleDeg,
|
maxLeanAngleDeg: resolvedMaxLean,
|
||||||
maxLeanSide: maxLeanSide,
|
maxLeanSide: resolvedLeanSide,
|
||||||
paceFactor: paceFactor,
|
paceFactor: paceFactor,
|
||||||
hazardCount: hazardCount,
|
hazardCount: hazardCount,
|
||||||
elevationFt: elevationFt,
|
elevationFt: resolvedElevationFt,
|
||||||
discipline: discipline,
|
discipline: discipline,
|
||||||
regionName: regionName,
|
regionName: regionName,
|
||||||
isFeatured: isFeatured,
|
isFeatured: isFeatured,
|
||||||
snapshotType: snapshotType,
|
snapshotType: snapshotType,
|
||||||
routePoints: routePoints,
|
routePoints: routePoints,
|
||||||
isGpsVerified: true,
|
telemetrySamples: gatedTelemetrySamples,
|
||||||
|
isGpsVerified: effectiveGpsVerified,
|
||||||
|
isDemo: false,
|
||||||
fistBumpCount: 1,
|
fistBumpCount: 1,
|
||||||
fistBumpUserIds: [authorUid],
|
fistBumpUserIds: [authorUid],
|
||||||
comments: const [],
|
comments: const [],
|
||||||
|
|
@ -504,3 +568,4 @@ class SocialFeedService {
|
||||||
_localFeedController.close();
|
_localFeedController.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
@ -20,6 +21,8 @@ import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||||
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
||||||
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
||||||
import 'package:motorcycle_dash/services/location_service.dart';
|
import 'package:motorcycle_dash/services/location_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/map_providers.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
|
||||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -344,6 +347,198 @@ void main() {
|
||||||
expect(find.text('Enable'), findsOneWidget);
|
expect(find.text('Enable'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('BUG-001: Touring Map Rider Follow & Recenter Regression Tests', () {
|
||||||
|
testWidgets(
|
||||||
|
'Touring map follows incoming GPS coordinates in portrait and landscape, pauses on manual pan, and resumes on recenter',
|
||||||
|
(tester) async {
|
||||||
|
await prefs.setString('dashboardLayout', 'touring');
|
||||||
|
configureViewport(tester, 400, 800);
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(DashboardMapPanel), findsOneWidget);
|
||||||
|
expect(find.byType(FlutterMap), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('dashboard_map_recenter_button')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit initial GPS fix within 50ms of mount (previously dropped by 500ms cooldown)
|
||||||
|
telemetryController.add(
|
||||||
|
TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
speed: 35.0,
|
||||||
|
heading: 90.0,
|
||||||
|
latitude: 34.0522,
|
||||||
|
longitude: -118.2437,
|
||||||
|
gpsAccuracy: 5.0,
|
||||||
|
gpsAgeMs: 100,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
var unifiedMap = tester.widget<UnifiedMapView>(
|
||||||
|
find.byType(UnifiedMapView),
|
||||||
|
);
|
||||||
|
var flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(unifiedMap.center.latitude, closeTo(34.0522, 0.0001));
|
||||||
|
expect(unifiedMap.center.longitude, closeTo(-118.2437, 0.0001));
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.latitude,
|
||||||
|
closeTo(34.0522, 0.0001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.longitude,
|
||||||
|
closeTo(-118.2437, 0.0001),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit rapid subsequent GPS fix within 100ms (tests trailing timer follow)
|
||||||
|
telemetryController.add(
|
||||||
|
TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
speed: 45.0,
|
||||||
|
heading: 95.0,
|
||||||
|
latitude: 34.0526,
|
||||||
|
longitude: -118.2431,
|
||||||
|
gpsAccuracy: 5.0,
|
||||||
|
gpsAgeMs: 80,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
// Advance past the 250ms trailing follow timer window
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.latitude,
|
||||||
|
closeTo(34.0526, 0.0001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.longitude,
|
||||||
|
closeTo(-118.2431, 0.0001),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate user manual pan / gesture interaction on the Touring map
|
||||||
|
final element = tester.element(find.byType(DashboardMapPanel));
|
||||||
|
final container = ProviderScope.containerOf(element);
|
||||||
|
container.read(mapFollowStateProvider.notifier).userInteracted();
|
||||||
|
flutterMap.mapController!.move(
|
||||||
|
flutterMap.mapController!.camera.center,
|
||||||
|
16.5,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Recenter button must appear when follow mode is disabled
|
||||||
|
final recenterFinder = find.byKey(
|
||||||
|
const ValueKey('dashboard_map_recenter_button'),
|
||||||
|
);
|
||||||
|
expect(recenterFinder, findsOneWidget);
|
||||||
|
|
||||||
|
// While panned (follow mode false), incoming GPS updates move the rider marker
|
||||||
|
// but do NOT hijack the user's panned camera center
|
||||||
|
final pannedCameraCenter = flutterMap.mapController!.camera.center;
|
||||||
|
telemetryController.add(
|
||||||
|
TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
speed: 50.0,
|
||||||
|
heading: 100.0,
|
||||||
|
latitude: 34.0600,
|
||||||
|
longitude: -118.2500,
|
||||||
|
gpsAccuracy: 5.0,
|
||||||
|
gpsAgeMs: 80,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.latitude,
|
||||||
|
closeTo(pannedCameraCenter.latitude, 0.0001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.longitude,
|
||||||
|
closeTo(pannedCameraCenter.longitude, 0.0001),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tap RECENTER button -> camera snaps back to latest rider position (34.0600, -118.2500)
|
||||||
|
await tester.tap(recenterFinder);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(recenterFinder, findsNothing);
|
||||||
|
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.latitude,
|
||||||
|
closeTo(34.0600, 0.0001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.longitude,
|
||||||
|
closeTo(-118.2500, 0.0001),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rotate to landscape Touring mode and verify GPS follow continues cleanly
|
||||||
|
configureViewport(tester, 800, 360);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
telemetryController.add(
|
||||||
|
TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
speed: 55.0,
|
||||||
|
heading: 110.0,
|
||||||
|
latitude: 34.0700,
|
||||||
|
longitude: -118.2600,
|
||||||
|
gpsAccuracy: 5.0,
|
||||||
|
gpsAgeMs: 80,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.latitude,
|
||||||
|
closeTo(34.0700, 0.0001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
flutterMap.mapController!.camera.center.longitude,
|
||||||
|
closeTo(-118.2600, 0.0001),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Touring map orientation button toggles between North Up and Heading Up (#80)',
|
||||||
|
(tester) async {
|
||||||
|
await prefs.setString('dashboardLayout', 'touring');
|
||||||
|
configureViewport(tester, 400, 800);
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final orientBtn = find.byKey(
|
||||||
|
const ValueKey('dashboard_map_orientation_button'),
|
||||||
|
);
|
||||||
|
expect(orientBtn, findsOneWidget);
|
||||||
|
expect(find.text('NORTH UP'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap orientation button to switch to Heading Up
|
||||||
|
await tester.tap(orientBtn);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 350));
|
||||||
|
|
||||||
|
expect(find.text('HEADING UP'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap again to switch back to North Up
|
||||||
|
await tester.tap(orientBtn);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 350));
|
||||||
|
|
||||||
|
expect(find.text('NORTH UP'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TestRideStateMachine extends StateNotifier<RideSession>
|
class _TestRideStateMachine extends StateNotifier<RideSession>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:motorcycle_dash/services/map/map_providers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('Map Smoothness & Orientation Angle Normalization (#79 & #80)', () {
|
||||||
|
test('shortestAngleDiff correctly calculates minimal circular angular distance', () {
|
||||||
|
// Direct small adjustments
|
||||||
|
expect(shortestAngleDiff(10.0, 20.0), closeTo(10.0, 0.001));
|
||||||
|
expect(shortestAngleDiff(20.0, 10.0), closeTo(-10.0, 0.001));
|
||||||
|
|
||||||
|
// Circular transition crossing 0° / 360°
|
||||||
|
// Moving from 359° to 1° should be +2° clockwise, NOT -358° counter-clockwise
|
||||||
|
expect(shortestAngleDiff(359.0, 1.0), closeTo(2.0, 0.001));
|
||||||
|
|
||||||
|
// Moving from 1° to 359° should be -2° counter-clockwise, NOT +358° clockwise
|
||||||
|
expect(shortestAngleDiff(1.0, 359.0), closeTo(-2.0, 0.001));
|
||||||
|
|
||||||
|
// 350° to 10° should be +20°
|
||||||
|
expect(shortestAngleDiff(350.0, 10.0), closeTo(20.0, 0.001));
|
||||||
|
|
||||||
|
// 10° to 350° should be -20°
|
||||||
|
expect(shortestAngleDiff(10.0, 350.0), closeTo(-20.0, 0.001));
|
||||||
|
|
||||||
|
// Half-circle
|
||||||
|
expect(shortestAngleDiff(0.0, 180.0).abs(), closeTo(180.0, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeAngle correctly constrains values to [0.0, 360.0)', () {
|
||||||
|
expect(normalizeAngle(0.0), closeTo(0.0, 0.001));
|
||||||
|
expect(normalizeAngle(360.0), closeTo(0.0, 0.001));
|
||||||
|
expect(normalizeAngle(365.0), closeTo(5.0, 0.001));
|
||||||
|
expect(normalizeAngle(-10.0), closeTo(350.0, 0.001));
|
||||||
|
expect(normalizeAngle(-370.0), closeTo(350.0, 0.001));
|
||||||
|
expect(normalizeAngle(720.0), closeTo(0.0, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('MapOrientation State & Persistence (#80)', () {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MapOrientationNotifier toggles and persists orientation', () async {
|
||||||
|
final notifier = MapOrientationNotifier();
|
||||||
|
expect(notifier.state, MapOrientation.northUp);
|
||||||
|
|
||||||
|
await notifier.toggle();
|
||||||
|
expect(notifier.state, MapOrientation.headingUp);
|
||||||
|
|
||||||
|
await notifier.toggle();
|
||||||
|
expect(notifier.state, MapOrientation.northUp);
|
||||||
|
|
||||||
|
await notifier.setOrientation(MapOrientation.headingUp);
|
||||||
|
expect(notifier.state, MapOrientation.headingUp);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString('selected_map_orientation'), 'headingUp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart';
|
||||||
|
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
|
||||||
|
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
|
||||||
|
|
||||||
|
/// Regression for Issue #52 / BUG-005 follow-up: the "Forensic CSV & PDF Export" Pro
|
||||||
|
/// upsell sheet showed "BOTTOM OVERFLOWED BY 90 PIXELS" in landscape on a Pixel 8a
|
||||||
|
/// (~914x411 dp) because showProUpgradeSheet built a non-scrolling Column inside a default
|
||||||
|
/// modal bottom sheet, which Flutter caps at 9/16 of the screen height.
|
||||||
|
class _FakeRideStateMachine extends StateNotifier<RideSession>
|
||||||
|
implements RideStateMachine {
|
||||||
|
_FakeRideStateMachine(super.initialState);
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpHost(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required Size viewport,
|
||||||
|
double textScale = 1.0,
|
||||||
|
}) async {
|
||||||
|
await tester.binding.setSurfaceSize(viewport);
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
rideStateMachineProvider.overrideWith(
|
||||||
|
(ref) => _FakeRideStateMachine(RideSession(state: RideState.idle)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(
|
||||||
|
size: viewport,
|
||||||
|
textScaler: TextScaler.linear(textScale),
|
||||||
|
),
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Consumer(
|
||||||
|
builder: (context, ref, _) => ElevatedButton(
|
||||||
|
onPressed: () => showProUpgradeSheet(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
feature: MotorSignalFeature.csvExport,
|
||||||
|
),
|
||||||
|
child: const Text('Open upsell'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
for (final viewport in const [
|
||||||
|
Size(914, 411), // Pixel 8a landscape
|
||||||
|
Size(800, 360),
|
||||||
|
Size(780, 360),
|
||||||
|
]) {
|
||||||
|
for (final scale in const [1.0, 1.3]) {
|
||||||
|
testWidgets(
|
||||||
|
'Pro upgrade sheet has no bottom overflow in landscape '
|
||||||
|
'${viewport.width.toInt()}x${viewport.height.toInt()} (text scale $scale)',
|
||||||
|
(tester) async {
|
||||||
|
await _pumpHost(tester, viewport: viewport, textScale: scale);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Open upsell'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Forensic CSV & PDF Export'), findsOneWidget);
|
||||||
|
expect(find.text('VIEW MOTORSIGNAL PRO'), findsOneWidget);
|
||||||
|
expect(find.text('NOT NOW'), findsOneWidget);
|
||||||
|
// No RenderFlex overflow exception.
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('NOT NOW is reachable and dismisses the sheet in landscape', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpHost(tester, viewport: const Size(800, 360), textScale: 1.3);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Open upsell'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.text('NOT NOW'));
|
||||||
|
await tester.tap(find.text('NOT NOW'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('VIEW MOTORSIGNAL PRO'), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue