From 121be97ecc1e38977e7263feab1f4d678fb8f8f2 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 23:18:01 -0400 Subject: [PATCH] fix: resolve dashboard and responsive UI defects --- .../widgets/evolution_portrait_dashboard.dart | 105 +++--- lib/features/entitlements/pro_screen.dart | 99 +++--- lib/features/garage/garage_screen.dart | 7 +- .../evolution_motorcycle_detail_view.dart | 34 +- .../garage/widgets/motorcycle_photo.dart | 31 ++ lib/features/history/ride_detail_screen.dart | 19 +- .../widgets/evolution_ride_details_view.dart | 305 +++++++++++------- .../settings/widgets/current_plan_card.dart | 8 +- lib/services/alerts/road_alert_providers.dart | 6 - lib/services/motorcycle_image_service.dart | 63 +++- ...lution_dashboard_truthful_status_test.dart | 107 ++++++ test/evolution_theme_test.dart | 4 +- ...motorcycle_image_selection_audit_test.dart | 14 + test/pro_screen_test.dart | 26 ++ test/ride_detail_responsive_test.dart | 103 ++++++ test/road_alert_real_providers_test.dart | 17 + 16 files changed, 702 insertions(+), 246 deletions(-) create mode 100644 test/evolution_dashboard_truthful_status_test.dart create mode 100644 test/ride_detail_responsive_test.dart create mode 100644 test/road_alert_real_providers_test.dart diff --git a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart index 61d59df..29ab743 100644 --- a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart +++ b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart @@ -33,6 +33,31 @@ class EvolutionPortraitDashboard extends ConsumerWidget { final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph); final totalG = math.sqrt(accelG * accelG + lateralG * lateralG); + final gpsAcc = telemetry?.gpsAccuracy; + final IconData gpsIcon; + final Color gpsColor; + final String gpsText; + + if (gpsAcc != null) { + if (gpsAcc <= 15.0) { + gpsIcon = Icons.gps_fixed; + gpsColor = const Color(0xFF00E5FF); + gpsText = 'GPS 3D FIX'; + } else if (gpsAcc <= 35.0) { + gpsIcon = Icons.gps_fixed; + gpsColor = Colors.amberAccent; + gpsText = 'GPS 2D FIX'; + } else { + gpsIcon = Icons.gps_not_fixed; + gpsColor = Colors.orangeAccent; + gpsText = 'GPS WEAK'; + } + } else { + gpsIcon = Icons.gps_off; + gpsColor = Colors.white38; + gpsText = 'NO GPS'; + } + return Container( color: Colors.black, child: Column( @@ -45,58 +70,62 @@ class EvolutionPortraitDashboard extends ConsumerWidget { children: [ Row( children: [ - const Icon(Icons.satellite_alt, color: Color(0xFF00E5FF), size: 16), + Icon(gpsIcon, color: gpsColor, size: 16), const SizedBox(width: 6), Text( - 'FIXED', + gpsText, style: TextStyle( fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w700, - color: Colors.white.withValues(alpha: 0.9), + color: gpsColor, letterSpacing: 0.1, ), ), ], ), - Row( - children: [ - const Icon(Icons.bluetooth_audio, color: Color(0xFF00E5FF), size: 16), - const SizedBox(width: 6), - Text( - 'HEADSET', - style: TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 12, - fontWeight: FontWeight.w700, - color: Colors.white.withValues(alpha: 0.9), - letterSpacing: 0.1, - ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isRecording + ? const Color(0xFF00E5FF).withValues(alpha: 0.15) + : const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: isRecording + ? const Color(0xFF00E5FF) + : Colors.white24, + width: 0.8, ), - ], - ), - Row( - children: [ - const Icon(Icons.signal_cellular_alt, color: Colors.white70, size: 16), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.white24, width: 0.8), - ), - child: const Text( - '92%', - style: TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 11, - fontWeight: FontWeight.w700, - color: Colors.white, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isRecording + ? const Color(0xFF00E5FF) + : Colors.white54, ), ), - ), - ], + const SizedBox(width: 6), + Text( + isRecording ? 'RECORDING' : 'STANDBY', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 10, + fontWeight: FontWeight.w700, + color: isRecording + ? const Color(0xFF00E5FF) + : Colors.white70, + letterSpacing: 0.5, + ), + ), + ], + ), ), ], ), diff --git a/lib/features/entitlements/pro_screen.dart b/lib/features/entitlements/pro_screen.dart index 74ececf..121e2b1 100644 --- a/lib/features/entitlements/pro_screen.dart +++ b/lib/features/entitlements/pro_screen.dart @@ -138,21 +138,24 @@ class _ProScreenState extends ConsumerState { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, - title: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'MOTORSIGNAL', - style: TextStyle( - fontFamily: 'Sora', - fontSize: 16, - fontWeight: FontWeight.w900, - letterSpacing: 1.5, + title: const FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'MOTORSIGNAL', + style: TextStyle( + fontFamily: 'Sora', + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 1.5, + ), ), - ), - SizedBox(width: 6), - ProBadge(), - ], + SizedBox(width: 6), + ProBadge(), + ], + ), ), actions: [ TextButton( @@ -501,7 +504,10 @@ class _ProScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, children: [ Text( title, @@ -513,8 +519,7 @@ class _ProScreenState extends ConsumerState { letterSpacing: 0.5, ), ), - if (badgeText != null) ...[ - const SizedBox(width: 8), + if (badgeText != null) Container( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -534,9 +539,7 @@ class _ProScreenState extends ConsumerState { ), ), ), - ], - if (isCurrentPlan) ...[ - const SizedBox(width: 8), + if (isCurrentPlan) Container( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -559,7 +562,6 @@ class _ProScreenState extends ConsumerState { ), ), ), - ], ], ), const SizedBox(height: 4), @@ -574,32 +576,39 @@ class _ProScreenState extends ConsumerState { ), ), const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - priceDisplay, - style: TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 17, - fontWeight: FontWeight.w900, - color: isHighlighted - ? const Color(0xFF00E5FF) - : Colors.white, + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 130), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + priceDisplay, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 17, + fontWeight: FontWeight.w900, + color: isHighlighted + ? const Color(0xFF00E5FF) + : Colors.white, + ), + ), ), - ), - const SizedBox(height: 2), - Text( - isCurrentPlan ? 'Active' : 'Select', - style: TextStyle( - fontSize: 11, - color: isHighlighted - ? const Color(0xFF00E5FF) - : Colors.white54, - fontWeight: FontWeight.bold, + const SizedBox(height: 2), + Text( + isCurrentPlan ? 'Active' : 'Select', + style: TextStyle( + fontSize: 11, + color: isHighlighted + ? const Color(0xFF00E5FF) + : Colors.white54, + fontWeight: FontWeight.bold, + ), ), - ), - ], + ], + ), ), ], ), diff --git a/lib/features/garage/garage_screen.dart b/lib/features/garage/garage_screen.dart index 857177d..985bb00 100644 --- a/lib/features/garage/garage_screen.dart +++ b/lib/features/garage/garage_screen.dart @@ -277,8 +277,11 @@ class GarageScreen extends ConsumerWidget { ], ), const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, children: [ TextButton.icon( icon: const Icon(Icons.directions_bike, size: 18), diff --git a/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart b/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart index 5add69b..8c21eec 100644 --- a/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart +++ b/lib/features/garage/widgets/evolution_motorcycle_detail_view.dart @@ -173,11 +173,35 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget { } Widget _buildBigKpi(String value, String unit) { - return Column( - children: [ - Text(value, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 26, fontWeight: FontWeight.w900, color: Colors.white)), - Text(unit, style: const TextStyle(fontFamily: 'Inter', fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF))), - ], + return Expanded( + child: Column( + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 26, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + unit, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 10, + fontWeight: FontWeight.w700, + color: Color(0xFF00E5FF), + ), + ), + ), + ], + ), ); } diff --git a/lib/features/garage/widgets/motorcycle_photo.dart b/lib/features/garage/widgets/motorcycle_photo.dart index 4371211..08c0677 100644 --- a/lib/features/garage/widgets/motorcycle_photo.dart +++ b/lib/features/garage/widgets/motorcycle_photo.dart @@ -26,7 +26,14 @@ class MotorcyclePhoto extends StatelessWidget { @override Widget build(BuildContext context) { + final initial = MotorcycleImageService.instance.getCachedOrCuratedImage( + year: year, + make: make, + model: model, + ); + return FutureBuilder( + initialData: initial, future: MotorcycleImageService.instance.resolve( year: year, make: make, @@ -34,6 +41,27 @@ class MotorcyclePhoto extends StatelessWidget { ), builder: (context, snapshot) { final image = snapshot.data; + if (image == null && snapshot.connectionState == ConnectionState.waiting) { + return ClipRRect( + borderRadius: borderRadius, + child: Container( + height: height, + width: double.infinity, + color: const Color(0xFF111111), + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF00E5FF), + ), + ), + ), + ), + ); + } + return ClipRRect( borderRadius: borderRadius, child: Container( @@ -94,6 +122,9 @@ class MotorcyclePhoto extends StatelessWidget { if (image.imageUrl != null) { return Image.network( image.imageUrl!, + headers: const { + 'User-Agent': 'MotorSignal/1.0 (motorcycle garage)', + }, fit: fit, filterQuality: FilterQuality.medium, loadingBuilder: (context, child, progress) { diff --git a/lib/features/history/ride_detail_screen.dart b/lib/features/history/ride_detail_screen.dart index d986916..7876458 100644 --- a/lib/features/history/ride_detail_screen.dart +++ b/lib/features/history/ride_detail_screen.dart @@ -283,15 +283,18 @@ class _RideDetailScreenState extends ConsumerState { .exportRide(r.id, asGpx: false); }, icon: const Icon(Icons.table_chart), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Export CSV'), - if (!isProCsv) ...[ - const SizedBox(width: 8), - const ProBadge(), + label: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Export CSV'), + if (!isProCsv) ...[ + const SizedBox(width: 8), + const ProBadge(), + ], ], - ], + ), ), ); }, diff --git a/lib/features/history/widgets/evolution_ride_details_view.dart b/lib/features/history/widgets/evolution_ride_details_view.dart index 5593963..006a254 100644 --- a/lib/features/history/widgets/evolution_ride_details_view.dart +++ b/lib/features/history/widgets/evolution_ride_details_view.dart @@ -52,14 +52,17 @@ class EvolutionRideDetailsView extends ConsumerWidget { icon: const Icon(Icons.arrow_back, color: Color(0xFF00E5FF)), onPressed: () => Navigator.of(context).pop(), ), - title: Text( - 'RIDE #${ride.id} TELEMETRY ARCHIVE', - style: TextStyle( - fontFamily: 'Inter', - fontSize: isLandscape ? 12 : 14, - fontWeight: FontWeight.w900, - color: Colors.white, - letterSpacing: 0.1, + title: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'RIDE #${ride.id} TELEMETRY ARCHIVE', + style: TextStyle( + fontFamily: 'Inter', + fontSize: isLandscape ? 12 : 14, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: 0.1, + ), ), ), actions: [ @@ -313,29 +316,32 @@ class EvolutionRideDetailsView extends ConsumerWidget { const SizedBox(height: 24), // Section 4: Action Buttons - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ReplayScreen(rideId: ride.id), - ), - ); - }, - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF00E5FF), - side: const BorderSide( - color: Color(0xFF00E5FF), - width: 1.5, - ), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 390; + + Widget buildReplayButton() => OutlinedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReplayScreen(rideId: ride.id), ), + ); + }, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide( + color: Color(0xFF00E5FF), + width: 1.5, ), - child: const Text( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Text( 'REPLAY RIDE', style: TextStyle( fontFamily: 'Sora', @@ -344,26 +350,27 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), ), ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton( - onPressed: () async { - final exporter = ref.read(exportServiceProvider); - await exporter.exportRide(ride.id, asGpx: true); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide( - color: Color(0xFF333333), - width: 1.2, - ), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + ); + + Widget buildGpxButton() => OutlinedButton( + onPressed: () async { + final exporter = ref.read(exportServiceProvider); + await exporter.exportRide(ride.id, asGpx: true); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide( + color: Color(0xFF333333), + width: 1.2, ), - child: const Text( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Text( 'EXPORT GPX', style: TextStyle( fontFamily: 'Sora', @@ -372,40 +379,42 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), ), ), - ), - const SizedBox(width: 8), - Expanded( - child: Builder( - builder: (context) { - final isProCsv = ref - .watch(featureAccessProvider) - .canAccess(MotorSignalFeature.csvExport); - return OutlinedButton( - onPressed: () async { - if (!isProCsv) { - showProUpgradeSheet( - context, - ref, - feature: MotorSignalFeature.csvExport, - ); - return; - } - final exporter = ref.read(exportServiceProvider); - await exporter.exportRide(ride.id, asGpx: false); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide( - color: Color(0xFF333333), - width: 1.2, - ), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + ); + + Widget buildCsvButton() => Builder( + builder: (context) { + final isProCsv = ref + .watch(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport); + return OutlinedButton( + onPressed: () async { + if (!isProCsv) { + showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.csvExport, + ); + return; + } + final exporter = ref.read(exportServiceProvider); + await exporter.exportRide(ride.id, asGpx: false); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide( + color: Color(0xFF333333), + width: 1.2, ), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, child: Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ const Text( 'EXPORT CSV', @@ -427,11 +436,40 @@ class EvolutionRideDetailsView extends ConsumerWidget { ], ], ), - ); - }, - ), - ), - ], + ), + ); + }, + ); + + if (isNarrow) { + return Column( + children: [ + Row( + children: [ + Expanded(child: buildReplayButton()), + const SizedBox(width: 8), + Expanded(child: buildGpxButton()), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: buildCsvButton(), + ), + ], + ); + } + + return Row( + children: [ + Expanded(child: buildReplayButton()), + const SizedBox(width: 8), + Expanded(child: buildGpxButton()), + const SizedBox(width: 8), + Expanded(child: buildCsvButton()), + ], + ); + }, ), const SizedBox(height: 24), @@ -913,41 +951,50 @@ class EvolutionRideDetailsView extends ConsumerWidget { String label2, String value2, ) { - return Row( - children: [ - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label1, - style: const TextStyle( - fontFamily: 'Inter', - fontSize: 11, - fontWeight: FontWeight.w700, - color: Colors.white54, - ), - ), - Text( - value1, - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 12, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - ), - ], - ), - ), - if (label2.isNotEmpty) ...[ - const SizedBox(width: 20), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Widget buildItem(String label, String value) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 135) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( - label2, + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white54, + ), + ), + const SizedBox(height: 2), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ], + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: 'Inter', fontSize: 11, @@ -955,8 +1002,12 @@ class EvolutionRideDetailsView extends ConsumerWidget { color: Colors.white54, ), ), - Text( - value2, + ), + const SizedBox(width: 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value, style: const TextStyle( fontFamily: 'JetBrains Mono', fontSize: 12, @@ -964,9 +1015,19 @@ class EvolutionRideDetailsView extends ConsumerWidget { color: Colors.white, ), ), - ], - ), - ), + ), + ], + ); + }, + ); + } + + return Row( + children: [ + Expanded(child: buildItem(label1, value1)), + if (label2.isNotEmpty) ...[ + const SizedBox(width: 12), + Expanded(child: buildItem(label2, value2)), ], ], ); diff --git a/lib/features/settings/widgets/current_plan_card.dart b/lib/features/settings/widgets/current_plan_card.dart index 18ad24d..932e752 100644 --- a/lib/features/settings/widgets/current_plan_card.dart +++ b/lib/features/settings/widgets/current_plan_card.dart @@ -108,10 +108,14 @@ class CurrentPlanCard extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 6, children: [ Row( + mainAxisSize: MainAxisSize.min, children: [ Icon( isPro ? Icons.verified : Icons.shield_outlined, diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart index d7a46b2..5bb3cca 100644 --- a/lib/services/alerts/road_alert_providers.dart +++ b/lib/services/alerts/road_alert_providers.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'road_alert_models.dart'; @@ -206,10 +205,5 @@ final activeRoadAlertProviders = Provider>((ref) { list.add(ref.watch(communityProviderInstance)); } - // Include Debug Mock Provider only in debug mode - if (kDebugMode) { - list.add(ref.watch(mockProviderInstance)); - } - return list; }); diff --git a/lib/services/motorcycle_image_service.dart b/lib/services/motorcycle_image_service.dart index eab8603..662613f 100644 --- a/lib/services/motorcycle_image_service.dart +++ b/lib/services/motorcycle_image_service.dart @@ -131,6 +131,16 @@ class MotorcycleImageService { ..._defaultDescriptors, ]; + MotorcycleImage? getCachedOrCuratedImage({ + required int year, + required String make, + required String model, + }) { + final key = _key(year, make, model); + if (_cache.containsKey(key)) return _cache[key]; + return _resolveCurated(make: make, model: model, year: year); + } + Future resolve({ required int year, required String make, @@ -146,7 +156,38 @@ class MotorcycleImageService { } try { - final query = '$year $make $model motorcycle'; + // Cascading query strategy: + // 1. Exact make + model + year + // 2. Broad make + model + final queries = [ + '$make $model $year', + '$make $model', + ]; + + for (final query in queries) { + final image = await _searchWikimedia(query, year, make, model); + if (image != null) { + _cache[key] = image; + return image; + } + } + + _cache[key] = null; + return null; + } catch (error) { + debugPrint('Motorcycle image lookup failed: $error'); + _cache[key] = null; + return null; + } + } + + Future _searchWikimedia( + String query, + int year, + String make, + String model, + ) async { + try { final uri = Uri.https('commons.wikimedia.org', '/w/api.php', { 'action': 'query', 'format': 'json', @@ -164,19 +205,13 @@ class MotorcycleImageService { final response = await http.get(uri, headers: const { 'Accept': 'application/json', 'User-Agent': 'MotorSignal/1.0 (motorcycle garage image lookup)', - }).timeout(const Duration(seconds: 8)); + }).timeout(const Duration(seconds: 6)); - if (response.statusCode != 200) { - _cache[key] = null; - return null; - } + if (response.statusCode != 200) return null; final json = jsonDecode(response.body) as Map; final pages = (json['query']?['pages'] as Map?)?.values; - if (pages == null) { - _cache[key] = null; - return null; - } + if (pages == null || pages.isEmpty) return null; MotorcycleImage? best; var bestScore = -1; @@ -211,7 +246,7 @@ class MotorcycleImageService { if (title.contains(normalizedYear)) score += 4; if (title.contains('motorcycle') || title.contains('motorbike')) score += 1; - if (score > bestScore) { + if (score >= 4 && score > bestScore) { bestScore = score; best = MotorcycleImage( imageUrl: thumb, @@ -225,12 +260,8 @@ class MotorcycleImageService { ); } } - - _cache[key] = best; return best; - } catch (error) { - debugPrint('Motorcycle image lookup failed: $error'); - _cache[key] = null; + } catch (_) { return null; } } diff --git a/test/evolution_dashboard_truthful_status_test.dart b/test/evolution_dashboard_truthful_status_test.dart new file mode 100644 index 0000000..1854d06 --- /dev/null +++ b/test/evolution_dashboard_truthful_status_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/evolution_portrait_dashboard.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + Widget buildDashboard({TelemetrySample? telemetry, RideSession? session}) { + return ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + child: MaterialApp( + home: Scaffold( + body: EvolutionPortraitDashboard( + currentSpeedMph: 45.0, + heading: 180.0, + leanAngle: 12.0, + accelG: 0.2, + lateralG: 0.1, + session: session ?? RideSession( + state: RideState.riding, + startTime: DateTime.now(), + tripDistanceMiles: 3.5, + topSpeedMph: 55.0, + averageSpeedMph: 42.0, + maxLeanLeft: 22.0, + maxLeanRight: 25.0, + maxAccelG: 0.6, + maxBrakingG: 0.8, + ), + telemetry: telemetry, + ), + ), + ), + ); + } + + testWidgets('displays GPS 3D FIX when accuracy <= 15m and no fake placeholders', (tester) async { + final sample = TelemetrySample( + timestamp: DateTime.now(), + gpsAccuracy: 8.5, + ); + + await tester.pumpWidget(buildDashboard(telemetry: sample)); + await tester.pump(); + + // Truthful GPS + expect(find.text('GPS 3D FIX'), findsOneWidget); + + // Placeholders removed + expect(find.text('FIXED'), findsNothing); + expect(find.text('HEADSET'), findsNothing); + expect(find.text('92%'), findsNothing); + + // Recording status badge + expect(find.text('RECORDING'), findsOneWidget); + }); + + testWidgets('displays GPS 2D FIX when accuracy is 25m', (tester) async { + final sample = TelemetrySample( + timestamp: DateTime.now(), + gpsAccuracy: 25.0, + ); + + await tester.pumpWidget(buildDashboard(telemetry: sample)); + await tester.pump(); + + expect(find.text('GPS 2D FIX'), findsOneWidget); + expect(find.text('HEADSET'), findsNothing); + expect(find.text('92%'), findsNothing); + }); + + testWidgets('displays GPS WEAK when accuracy is 50m', (tester) async { + final sample = TelemetrySample( + timestamp: DateTime.now(), + gpsAccuracy: 50.0, + ); + + await tester.pumpWidget(buildDashboard(telemetry: sample)); + await tester.pump(); + + expect(find.text('GPS WEAK'), findsOneWidget); + expect(find.text('HEADSET'), findsNothing); + }); + + testWidgets('displays NO GPS when telemetry is null', (tester) async { + await tester.pumpWidget(buildDashboard(telemetry: null)); + await tester.pump(); + + expect(find.text('NO GPS'), findsOneWidget); + expect(find.text('HEADSET'), findsNothing); + expect(find.text('92%'), findsNothing); + }); +} diff --git a/test/evolution_theme_test.dart b/test/evolution_theme_test.dart index af2b384..42fc1db 100644 --- a/test/evolution_theme_test.dart +++ b/test/evolution_theme_test.dart @@ -70,8 +70,8 @@ void main() { expect(find.text('136'), findsOneWidget); expect(find.text('MPH'), findsOneWidget); - expect(find.text('FIXED'), findsOneWidget); - expect(find.text('HEADSET'), findsOneWidget); + expect(find.text('NO GPS'), findsOneWidget); + expect(find.text('HEADSET'), findsNothing); expect(find.text('BRAKING'), findsOneWidget); expect(find.text('CORNERING'), findsOneWidget); expect(find.text('LEAN ANGLE'), findsOneWidget); diff --git a/test/motorcycle_image_selection_audit_test.dart b/test/motorcycle_image_selection_audit_test.dart index 583e962..16ece33 100644 --- a/test/motorcycle_image_selection_audit_test.dart +++ b/test/motorcycle_image_selection_audit_test.dart @@ -154,5 +154,19 @@ void main() { expect(file.lengthSync(), greaterThan(1000), reason: 'Asset file $assetPath is empty or corrupt'); } }); + + test('9. getCachedOrCuratedImage synchronously provides curated photo', () { + final img = service.getCachedOrCuratedImage(make: 'Yamaha', model: 'YZF-R6', year: 2001); + expect(img, isNotNull); + expect(img!.isPhoto, isTrue); + expect(img.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); + }); + + test('10. resolve returns curated photo directly without network', () async { + final img = await service.resolve(make: 'Yamaha', model: 'YZF-R6', year: 2001); + expect(img, isNotNull); + expect(img!.isPhoto, isTrue); + expect(img.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); + }); }); } diff --git a/test/pro_screen_test.dart b/test/pro_screen_test.dart index 5b91b12..6adb486 100644 --- a/test/pro_screen_test.dart +++ b/test/pro_screen_test.dart @@ -249,5 +249,31 @@ void main() { ); expect(find.text('MANAGE SUBSCRIPTION'), findsOneWidget); }); + + testWidgets('renders on narrow viewport without overflow', (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final fakeBilling = FakeBillingService(); + await tester.pumpWidget( + buildTestableWidget( + child: const ProScreen(), + overrides: [ + billingServiceProvider.overrideWithValue(fakeBilling), + entitlementStorageProvider.overrideWithValue( + InMemoryEntitlementStorage(), + ), + ], + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.takeException(), isNull); + expect(find.text('PRO LIFETIME'), findsOneWidget); + expect(find.text('BEST VALUE'), findsOneWidget); + }); }); } diff --git a/test/ride_detail_responsive_test.dart b/test/ride_detail_responsive_test.dart new file mode 100644 index 0000000..55366c3 --- /dev/null +++ b/test/ride_detail_responsive_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/features/history/widgets/evolution_ride_details_view.dart'; +import 'package:motorcycle_dash/services/github/github_sync_service.dart'; + +class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService { + @override + GitHubUploadState get state => GitHubUploadState.idle; + @override + String get statusMessage => ''; + @override + Set get queuedRideIds => const {}; + @override + bool isRideUploaded(int rideId) => false; + @override + String? getRideCommitSha(int rideId) => null; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Ride makeTestRide() { + return Ride( + id: 1, + startTime: DateTime(2026, 1, 1, 10, 0), + endTime: DateTime(2026, 1, 1, 10, 45), + distanceMiles: 25.5, + topSpeedMph: 65.0, + movingTime: 2400, + stoppedTime: 300, + distance: 25.5, + topSpeed: 65.0, + averageSpeed: 42.0, + elevationGain: 350.0, + elevationLoss: 300.0, + maxLeanLeft: 34.0, + maxLeanRight: 36.0, + maxAccelG: 0.75, + maxBrakingG: 0.85, + ); + } + + testWidgets('renders on narrow 320px screen without overflow', (tester) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final ride = makeTestRide(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()), + ], + child: MaterialApp( + home: EvolutionRideDetailsView( + ride: ride, + events: const [], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('REPLAY RIDE'), findsOneWidget); + expect(find.text('EXPORT GPX'), findsOneWidget); + expect(find.text('EXPORT CSV'), findsOneWidget); + }); + + testWidgets('renders on standard 360px screen without overflow', (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final ride = makeTestRide(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()), + ], + child: MaterialApp( + home: EvolutionRideDetailsView( + ride: ride, + events: const [], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('REPLAY RIDE'), findsOneWidget); + expect(find.text('EXPORT GPX'), findsOneWidget); + expect(find.text('EXPORT CSV'), findsOneWidget); + }); +} diff --git a/test/road_alert_real_providers_test.dart b/test/road_alert_real_providers_test.dart new file mode 100644 index 0000000..1814d2a --- /dev/null +++ b/test/road_alert_real_providers_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('activeRoadAlertProviders does not automatically include mock provider', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final providers = container.read(activeRoadAlertProviders); + // Mock provider id is 'mock_police_debug' + expect(providers.any((p) => p.id == 'mock_police_debug'), isFalse, + reason: 'Mock police/speedcam alerts must not be injected automatically in live alerts'); + }); +}