import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:drift/drift.dart' hide Column; import '../../database/database.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/unified_map_view.dart'; import '../../core/theme/theme_extensions.dart'; /// Corner performance metrics and apex grading for forensic telemetry replay. class CornerAnalysis { final int id; final String direction; // 'Left' or 'Right' final double entrySpeed; final double apexSpeed; final double exitSpeed; final double apexLeanAngle; final String grade; // 'A+', 'A', 'B', 'C', 'D' final int startIndex; final int apexIndex; final int endIndex; const CornerAnalysis({ required this.id, required this.direction, required this.entrySpeed, required this.apexSpeed, required this.exitSpeed, required this.apexLeanAngle, required this.grade, required this.startIndex, required this.apexIndex, required this.endIndex, }); double get speedDelta => exitSpeed - entrySpeed; Color getGradeColor() { switch (grade) { case 'A+': case 'A': return const Color(0xFF10B981); // Emerald case 'B': return Colors.cyanAccent; case 'C': return Colors.amberAccent; case 'D': default: return const Color(0xFFFF334B); // Red } } } class ReplayScreen extends ConsumerStatefulWidget { final int rideId; final bool autoPlay; const ReplayScreen({super.key, required this.rideId, this.autoPlay = false}); @override ConsumerState createState() => _ReplayScreenState(); } class _ReplayScreenState extends ConsumerState { bool _isLoading = true; List _telemetry = []; List _routePoints = []; List _heatmapPolylines = []; List _corners = []; // Playback state bool _isPlaying = false; double _playbackSpeed = 1.0; int _currentIndex = 0; Timer? _playbackTimer; double _zoom = 17.0; bool _isFollowingRider = true; @override void initState() { super.initState(); _loadData(); } @override void dispose() { _playbackTimer?.cancel(); super.dispose(); } Future _loadData() async { final db = ref.read(databaseProvider); var targetRideId = widget.rideId; if (targetRideId == 0) { final latestRide = await (db.select(db.rides) ..orderBy([(r) => OrderingTerm(expression: r.id, mode: OrderingMode.desc)]) ..limit(1)) .getSingleOrNull(); if (latestRide != null) { targetRideId = latestRide.id; } } final telemetry = await (db.select(db.telemetrySamples) ..where((t) => t.rideId.equals(targetRideId)) ..orderBy([(t) => OrderingTerm(expression: t.timestamp)])) .get(); if (telemetry.isNotEmpty) { _routePoints = telemetry .where((t) => t.latitude != null && t.longitude != null) .map((t) => AppLatLng(t.latitude!, t.longitude!)) .toList(); _heatmapPolylines = _buildHeatmapPolylines(telemetry); _corners = _detectCorners(telemetry); } if (mounted) { setState(() { _telemetry = telemetry; _isLoading = false; }); if (widget.autoPlay && telemetry.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_isPlaying) { _togglePlayback(); } }); } } } /// Builds multi-color lean angle route heatmap: /// Yellow (<=20° straight/mild) -> Cyan (20°-35° cornering) -> Red (>35° apex peak). List _buildHeatmapPolylines(List telemetry) { final List polylines = []; final validSamples = telemetry.where((t) => t.latitude != null && t.longitude != null).toList(); if (validSamples.length < 2) return polylines; Color getColorForLean(double absLean) { if (absLean > 35.0) { return const Color(0xFFFF334B); // Red apex } else if (absLean > 20.0) { return Colors.cyanAccent; // Cyan mid-lean } else { return Colors.amberAccent; // Yellow mild/straight } } double getWidthForLean(double absLean) { if (absLean > 35.0) return 6.0; if (absLean > 20.0) return 4.5; return 3.5; } List currentSegment = [AppLatLng(validSamples[0].latitude!, validSamples[0].longitude!)]; Color currentColor = getColorForLean((validSamples[0].leanAngle ?? 0.0).abs()); double currentWidth = getWidthForLean((validSamples[0].leanAngle ?? 0.0).abs()); for (int i = 1; i < validSamples.length; i++) { final sample = validSamples[i]; final absLean = (sample.leanAngle ?? 0.0).abs(); final sampleColor = getColorForLean(absLean); final sampleWidth = getWidthForLean(absLean); final pt = AppLatLng(sample.latitude!, sample.longitude!); if (sampleColor == currentColor) { currentSegment.add(pt); } else { currentSegment.add(pt); // Overlap adjacent segments to avoid rendering seams polylines.add(AppPolyline( id: 'heatmap_${polylines.length}', points: List.from(currentSegment), color: currentColor, width: currentWidth, )); currentSegment = [pt]; currentColor = sampleColor; currentWidth = sampleWidth; } } if (currentSegment.length >= 2) { polylines.add(AppPolyline( id: 'heatmap_${polylines.length}', points: currentSegment, color: currentColor, width: currentWidth, )); } return polylines; } /// Automatically parses corners, detects apexes, and grades cornering efficiency. List _detectCorners(List telemetry) { final List corners = []; if (telemetry.length < 10) return corners; bool inCorner = false; int startIndex = 0; double maxLean = 0.0; int apexIdx = 0; String dir = 'Left'; for (int i = 0; i < telemetry.length; i++) { final lean = telemetry[i].leanAngle ?? 0.0; final absLean = lean.abs(); if (absLean > 14.0) { if (!inCorner) { inCorner = true; startIndex = (i > 0) ? i - 1 : 0; maxLean = absLean; apexIdx = i; dir = lean < 0 ? 'Left' : 'Right'; } else { if (absLean > maxLean) { maxLean = absLean; apexIdx = i; dir = lean < 0 ? 'Left' : 'Right'; } } } else { if (inCorner) { final endIndex = i; final durationCount = endIndex - startIndex; if (durationCount >= 4 && maxLean >= 16.0) { final entrySpeed = telemetry[startIndex].speed ?? 0.0; final apexSpeed = telemetry[apexIdx].speed ?? 0.0; final exitSpeed = telemetry[endIndex].speed ?? 0.0; final speedRetention = entrySpeed > 0 ? (apexSpeed / entrySpeed) : 1.0; final speedExitDelta = exitSpeed - apexSpeed; String grade = 'B'; if (maxLean >= 35.0 && speedRetention >= 0.82 && speedExitDelta >= 0) { grade = 'A+'; } else if (maxLean >= 28.0 && speedRetention >= 0.75) { grade = 'A'; } else if (maxLean >= 20.0) { grade = 'B'; } else if (speedRetention < 0.6) { grade = 'D'; } else { grade = 'C'; } corners.add(CornerAnalysis( id: corners.length + 1, direction: dir, entrySpeed: entrySpeed, apexSpeed: apexSpeed, exitSpeed: exitSpeed, apexLeanAngle: maxLean, grade: grade, startIndex: startIndex, apexIndex: apexIdx, endIndex: endIndex, )); } inCorner = false; maxLean = 0.0; } } } return corners; } void _togglePlayback() { setState(() { _isPlaying = !_isPlaying; }); if (_isPlaying) { if (_currentIndex >= _telemetry.length - 1) { _currentIndex = 0; // restart } _scheduleNextFrame(); } else { _playbackTimer?.cancel(); } } void _scheduleNextFrame() { if (!_isPlaying || _currentIndex >= _telemetry.length - 1) { setState(() => _isPlaying = false); return; } final currentPoint = _telemetry[_currentIndex]; final nextPoint = _telemetry[_currentIndex + 1]; final diffMs = nextPoint.timestamp.difference(currentPoint.timestamp).inMilliseconds; final waitMs = (diffMs.clamp(0, 1000) / _playbackSpeed).round(); _playbackTimer = Timer(Duration(milliseconds: waitMs), () { if (mounted) { setState(() { _currentIndex++; }); _scheduleNextFrame(); } }); } void _changeSpeed() { setState(() { if (_playbackSpeed == 0.25) { _playbackSpeed = 0.5; } else if (_playbackSpeed == 0.5) { _playbackSpeed = 1.0; } else if (_playbackSpeed == 1.0) { _playbackSpeed = 2.0; } else if (_playbackSpeed == 2.0) { _playbackSpeed = 4.0; } else { _playbackSpeed = 0.25; } }); } @override Widget build(BuildContext context) { final colors = context.motorColors; final theme = Theme.of(context); final hasLeanData = _telemetry.any((t) => t.leanAngle != null); if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); if (_telemetry.isEmpty) { return Scaffold( backgroundColor: const Color(0xFF101419), appBar: AppBar( backgroundColor: const Color(0xFF0B0E13), title: const Text('Ride Replay'), leading: IconButton( icon: const Icon(Icons.arrow_back_rounded), onPressed: () { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } else { context.go('/activity'); } }, ), ), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.analytics_outlined, size: 56, color: Color(0xFF4A5568)), const SizedBox(height: 16), const Text( 'No telemetry data available for replay.', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white70, ), ), const SizedBox(height: 8), const Text( 'Record or restore a ride with telemetry samples to replay.', style: TextStyle( fontSize: 12, color: Color(0xFF8B98A5), ), ), ], ), ), ); } final currentSample = _telemetry[_currentIndex]; final currentPos = (currentSample.latitude != null && currentSample.longitude != null) ? AppLatLng(currentSample.latitude!, currentSample.longitude!) : (_routePoints.isNotEmpty ? _routePoints.first : const AppLatLng(0, 0)); final riderMarker = AppMapMarker( id: 'replay_rider', position: currentPos, heading: currentSample.heading ?? 0.0, iconWidget: const Icon( Icons.navigation, color: Colors.cyanAccent, size: 38, ), ); // Apex Markers for detected corners on the map final apexMarkers = _corners .where((c) => c.apexIndex < _telemetry.length && _telemetry[c.apexIndex].latitude != null && _telemetry[c.apexIndex].longitude != null) .map((c) { final s = _telemetry[c.apexIndex]; final isCurrent = _currentIndex >= c.startIndex && _currentIndex <= c.endIndex; return AppMapMarker( id: 'apex_${c.id}', position: AppLatLng(s.latitude!, s.longitude!), heading: 0, onTap: () => setState(() => _currentIndex = c.apexIndex), iconWidget: Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( color: isCurrent ? const Color(0xFFFF334B) : Colors.black87, borderRadius: BorderRadius.circular(6), border: Border.all( color: isCurrent ? Colors.white : c.getGradeColor(), width: isCurrent ? 2 : 1.2, ), ), child: Text( 'T${c.id}', style: const TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white, fontFamily: 'JetBrains Mono', ), ), ), ); }).toList(); final allMarkers = [riderMarker, ...apexMarkers]; final polylines = _heatmapPolylines.isNotEmpty ? _heatmapPolylines : (_routePoints.length >= 2 ? [ AppPolyline( id: 'replay_route', points: _routePoints, color: Colors.cyanAccent, width: 5.0, ) ] : []); final maxSliderValue = _telemetry.length > 1 ? (_telemetry.length - 1).toDouble() : 1.0; final currentSliderValue = _telemetry.length > 1 ? _currentIndex.toDouble().clamp(0.0, maxSliderValue) : 0.0; return Scaffold( appBar: AppBar( title: Text('Replay - Ride ${widget.rideId}'), actions: [ Padding( padding: const EdgeInsets.only(right: 16.0), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: colors.cyberCyan.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), border: Border.all(color: colors.cyberCyan.withValues(alpha: 0.4)), ), child: Text( '${_corners.length} Corners Graded', style: TextStyle( fontSize: 11, fontFamily: 'JetBrains Mono', fontWeight: FontWeight.bold, color: colors.cyberCyan, ), ), ), ), ), ], ), body: Column( children: [ // Map View (with multi-color lean heatmap and telemetry HUD overlay) Expanded( flex: 5, child: Stack( children: [ UnifiedMapView( center: currentPos, zoom: _zoom, markers: allMarkers, polylines: polylines, isFollowModeOverride: _isFollowingRider, onUserInteraction: () { if (_isFollowingRider) { setState(() => _isFollowingRider = false); } }, ), // Map Navigation Controls (Zoom In, Zoom Out, Recenter) Positioned( right: 14, bottom: 14, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (!_isFollowingRider) ...[ _ReplayMapButton( key: const ValueKey('replay_recenter_button'), icon: Icons.my_location, tooltip: 'Recenter on Rider', color: colors.cyberCyan, backgroundColor: colors.cyberCyan.withValues(alpha: 0.25), onTap: () => setState(() => _isFollowingRider = true), ), const SizedBox(height: 8), ], _ReplayMapButton( key: const ValueKey('replay_zoom_in_button'), icon: Icons.add, tooltip: 'Zoom In', onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)), ), const SizedBox(height: 8), _ReplayMapButton( key: const ValueKey('replay_zoom_out_button'), icon: Icons.remove, tooltip: 'Zoom Out', onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)), ), ], ), ), // Telemetry Overlay Positioned( top: 14, left: 14, right: 14, child: Center( child: FittedBox( fit: BoxFit.scaleDown, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'), const SizedBox(width: 8), _buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'), const SizedBox(width: 8), _buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'), ], ), ), ), ), // Heatmap Legend Overlay Positioned( bottom: 10, left: 14, right: 14, child: Align( alignment: Alignment.centerLeft, child: FittedBox( fit: BoxFit.scaleDown, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white24), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildLegendItem(Colors.amberAccent, '≤20°'), const SizedBox(width: 8), _buildLegendItem(Colors.cyanAccent, '20°-35°'), const SizedBox(width: 8), _buildLegendItem(const Color(0xFFFF334B), '>35° Apex'), ], ), ), ), ), ), ], ), ), // Playback Controls Bar Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: theme.colorScheme.surface, child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( currentSample.timestamp.toLocal().toString().split('.')[0], overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'JetBrains Mono', ), ), ), const SizedBox(width: 8), Text( 'Sample ${_currentIndex + 1} / ${_telemetry.length}', style: TextStyle( color: theme.colorScheme.onSurface.withValues(alpha: 0.6), fontSize: 11, fontFamily: 'JetBrains Mono', ), ), ], ), Slider( value: currentSliderValue, min: 0, max: maxSliderValue, onChanged: _telemetry.length > 1 ? (val) { setState(() { _currentIndex = val.toInt(); }); } : null, activeColor: Colors.cyanAccent, ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.restart_alt), tooltip: 'Restart', onPressed: () { setState(() { _currentIndex = 0; }); }, ), const SizedBox(width: 16), FloatingActionButton.small( onPressed: _togglePlayback, child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), ), const SizedBox(width: 16), TextButton( onPressed: _changeSpeed, child: Text( '${_playbackSpeed}x', style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono'), ), ), ], ), ], ), ), // Corner Entry / Apex / Exit Breakdown Table Expanded( flex: _corners.isEmpty ? 3 : 4, child: Container( color: theme.colorScheme.surfaceContainerLow, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), color: theme.colorScheme.surfaceContainer, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( 'CORNER APEX BREAKDOWN & GRADING', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono', color: colors.cyberCyan, letterSpacing: 0.8, ), ), ), const SizedBox(width: 8), Text( _corners.isEmpty ? '' : 'Tap row to seek apex', style: TextStyle( fontSize: 10, fontFamily: 'JetBrains Mono', color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), ), ], ), ), if (_corners.isEmpty) Expanded( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: FittedBox( fit: BoxFit.scaleDown, child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( hasLeanData ? Icons.straighten_rounded : Icons.sensors_off_rounded, size: 26, color: hasLeanData ? colors.cyberCyan.withValues(alpha: 0.7) : colors.warningAmber.withValues(alpha: 0.8), ), const SizedBox(height: 6), Text( hasLeanData ? 'No distinct corners detected over 15° lean threshold.' : 'Sensor Fusion Uncalibrated / No Lean Telemetry', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface.withValues(alpha: 0.85), fontFamily: 'JetBrains Mono', ), ), const SizedBox(height: 4), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 280), child: Text( hasLeanData ? 'Upright riding recorded. Lean angle remained below 15° throughout this session.' : 'Lean angle was not recorded or IMU was uncalibrated during this session.', textAlign: TextAlign.center, style: TextStyle( fontSize: 11, color: theme.colorScheme.onSurface.withValues(alpha: 0.55), fontFamily: 'Inter', ), ), ), ], ), ), ), ), ) else Expanded( child: ListView.separated( itemCount: _corners.length, separatorBuilder: (c, i) => Divider( color: colors.outlineVariant.withValues(alpha: 0.25), height: 1, ), itemBuilder: (context, index) { final corner = _corners[index]; final isCurrent = _currentIndex >= corner.startIndex && _currentIndex <= corner.endIndex; return InkWell( onTap: () { setState(() { _currentIndex = corner.apexIndex; }); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), color: isCurrent ? colors.cyberCyan.withValues(alpha: 0.12) : Colors.transparent, child: Row( children: [ // Corner ID & Direction SizedBox( width: 75, child: Row( children: [ Text( 'T${corner.id}', style: const TextStyle( fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono', fontSize: 13, ), ), const SizedBox(width: 4), Text( corner.direction == 'Left' ? '↰ L' : '↱ R', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: corner.direction == 'Left' ? Colors.blueAccent : Colors.greenAccent, fontFamily: 'JetBrains Mono', ), ), ], ), ), // Entry Speed Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'ENTRY', style: TextStyle( fontSize: 9, color: theme.colorScheme.onSurface.withValues(alpha: 0.5), fontFamily: 'JetBrains Mono', ), ), Text( '${corner.entrySpeed.toStringAsFixed(0)} mph', style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'), ), ], ), ), // Apex Speed & Lean Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'APEX', style: TextStyle( fontSize: 9, color: theme.colorScheme.onSurface.withValues(alpha: 0.5), fontFamily: 'JetBrains Mono', ), ), Text( '${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°', style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: colors.cyberCyan, fontFamily: 'JetBrains Mono', ), ), ], ), ), // Exit Speed & Delta Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'EXIT (Δ)', style: TextStyle( fontSize: 9, color: theme.colorScheme.onSurface.withValues(alpha: 0.5), fontFamily: 'JetBrains Mono', ), ), Text( '${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})', style: TextStyle( fontSize: 12, color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent, fontFamily: 'JetBrains Mono', ), ), ], ), ), // Grade Badge Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: corner.getGradeColor().withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)), ), child: Text( corner.grade, style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono', color: corner.getGradeColor(), ), ), ), ], ), ), ); }, ), ), ], ), ), ), ], ), ); } Widget _buildLegendItem(Color color, String text) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 4), Text( text, style: const TextStyle(fontSize: 10, color: Colors.white, fontFamily: 'JetBrains Mono'), ), ], ); } Widget _buildOverlayStat(String label, String value) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)), ), child: Column( children: [ Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)), Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)), ], ), ); } } class _ReplayMapButton extends StatelessWidget { final IconData icon; final String tooltip; final VoidCallback onTap; final Color? color; final Color? backgroundColor; const _ReplayMapButton({ super.key, required this.icon, required this.tooltip, required this.onTap, this.color, this.backgroundColor, }); @override Widget build(BuildContext context) { return Material( color: backgroundColor ?? Colors.black87, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: BorderSide(color: (color ?? Colors.white).withValues(alpha: 0.3)), ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Tooltip( message: tooltip, child: SizedBox( width: 48, height: 48, child: Icon( icon, color: color ?? Colors.white, size: 24, ), ), ), ), ); } }