fix: resolve dashboard and responsive UI defects

This commit is contained in:
Developer 2026-09-03 23:18:01 -04:00
parent 173c99d450
commit 121be97ecc
16 changed files with 702 additions and 246 deletions

View File

@ -33,6 +33,31 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph); final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph);
final totalG = math.sqrt(accelG * accelG + lateralG * lateralG); 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( return Container(
color: Colors.black, color: Colors.black,
child: Column( child: Column(
@ -45,58 +70,62 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Icons.satellite_alt, color: Color(0xFF00E5FF), size: 16), Icon(gpsIcon, color: gpsColor, size: 16),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'FIXED', gpsText,
style: TextStyle( style: TextStyle(
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white.withValues(alpha: 0.9), color: gpsColor,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
), ),
], ],
), ),
Row( Container(
children: [ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
const Icon(Icons.bluetooth_audio, color: Color(0xFF00E5FF), size: 16), decoration: BoxDecoration(
const SizedBox(width: 6), color: isRecording
Text( ? const Color(0xFF00E5FF).withValues(alpha: 0.15)
'HEADSET', : const Color(0xFF1A1A1A),
style: TextStyle( borderRadius: BorderRadius.circular(4),
fontFamily: 'JetBrains Mono', border: Border.all(
fontSize: 12, color: isRecording
fontWeight: FontWeight.w700, ? const Color(0xFF00E5FF)
color: Colors.white.withValues(alpha: 0.9), : Colors.white24,
letterSpacing: 0.1, width: 0.8,
),
), ),
], ),
), child: Row(
Row( mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.signal_cellular_alt, color: Colors.white70, size: 16), Container(
const SizedBox(width: 6), width: 6,
Container( height: 6,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(
decoration: BoxDecoration( shape: BoxShape.circle,
color: const Color(0xFF1A1A1A), color: isRecording
borderRadius: BorderRadius.circular(4), ? const Color(0xFF00E5FF)
border: Border.all(color: Colors.white24, width: 0.8), : Colors.white54,
),
child: const Text(
'92%',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white,
), ),
), ),
), 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,
),
),
],
),
), ),
], ],
), ),

View File

@ -138,21 +138,24 @@ class _ProScreenState extends ConsumerState<ProScreen> {
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
title: const Row( title: const FittedBox(
mainAxisSize: MainAxisSize.min, fit: BoxFit.scaleDown,
children: [ child: Row(
Text( mainAxisSize: MainAxisSize.min,
'MOTORSIGNAL', children: [
style: TextStyle( Text(
fontFamily: 'Sora', 'MOTORSIGNAL',
fontSize: 16, style: TextStyle(
fontWeight: FontWeight.w900, fontFamily: 'Sora',
letterSpacing: 1.5, fontSize: 16,
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
),
), ),
), SizedBox(width: 6),
SizedBox(width: 6), ProBadge(),
ProBadge(), ],
], ),
), ),
actions: [ actions: [
TextButton( TextButton(
@ -501,7 +504,10 @@ class _ProScreenState extends ConsumerState<ProScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 4,
children: [ children: [
Text( Text(
title, title,
@ -513,8 +519,7 @@ class _ProScreenState extends ConsumerState<ProScreen> {
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
if (badgeText != null) ...[ if (badgeText != null)
const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
@ -534,9 +539,7 @@ class _ProScreenState extends ConsumerState<ProScreen> {
), ),
), ),
), ),
], if (isCurrentPlan)
if (isCurrentPlan) ...[
const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
@ -559,7 +562,6 @@ class _ProScreenState extends ConsumerState<ProScreen> {
), ),
), ),
), ),
],
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -574,32 +576,39 @@ class _ProScreenState extends ConsumerState<ProScreen> {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column( ConstrainedBox(
crossAxisAlignment: CrossAxisAlignment.end, constraints: const BoxConstraints(maxWidth: 130),
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.end,
priceDisplay, mainAxisSize: MainAxisSize.min,
style: TextStyle( children: [
fontFamily: 'JetBrains Mono', FittedBox(
fontSize: 17, fit: BoxFit.scaleDown,
fontWeight: FontWeight.w900, child: Text(
color: isHighlighted priceDisplay,
? const Color(0xFF00E5FF) style: TextStyle(
: Colors.white, fontFamily: 'JetBrains Mono',
fontSize: 17,
fontWeight: FontWeight.w900,
color: isHighlighted
? const Color(0xFF00E5FF)
: Colors.white,
),
),
), ),
), const SizedBox(height: 2),
const SizedBox(height: 2), Text(
Text( isCurrentPlan ? 'Active' : 'Select',
isCurrentPlan ? 'Active' : 'Select', style: TextStyle(
style: TextStyle( fontSize: 11,
fontSize: 11, color: isHighlighted
color: isHighlighted ? const Color(0xFF00E5FF)
? const Color(0xFF00E5FF) : Colors.white54,
: Colors.white54, fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, ),
), ),
), ],
], ),
), ),
], ],
), ),

View File

@ -277,8 +277,11 @@ class GarageScreen extends ConsumerWidget {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceBetween, alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 4,
children: [ children: [
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.directions_bike, size: 18), icon: const Icon(Icons.directions_bike, size: 18),

View File

@ -173,11 +173,35 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
} }
Widget _buildBigKpi(String value, String unit) { Widget _buildBigKpi(String value, String unit) {
return Column( return Expanded(
children: [ child: Column(
Text(value, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 26, fontWeight: FontWeight.w900, color: Colors.white)), children: [
Text(unit, style: const TextStyle(fontFamily: 'Inter', fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF))), 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),
),
),
),
],
),
); );
} }

View File

@ -26,7 +26,14 @@ class MotorcyclePhoto extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final initial = MotorcycleImageService.instance.getCachedOrCuratedImage(
year: year,
make: make,
model: model,
);
return FutureBuilder<MotorcycleImage?>( return FutureBuilder<MotorcycleImage?>(
initialData: initial,
future: MotorcycleImageService.instance.resolve( future: MotorcycleImageService.instance.resolve(
year: year, year: year,
make: make, make: make,
@ -34,6 +41,27 @@ class MotorcyclePhoto extends StatelessWidget {
), ),
builder: (context, snapshot) { builder: (context, snapshot) {
final image = snapshot.data; 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( return ClipRRect(
borderRadius: borderRadius, borderRadius: borderRadius,
child: Container( child: Container(
@ -94,6 +122,9 @@ class MotorcyclePhoto extends StatelessWidget {
if (image.imageUrl != null) { if (image.imageUrl != null) {
return Image.network( return Image.network(
image.imageUrl!, image.imageUrl!,
headers: const {
'User-Agent': 'MotorSignal/1.0 (motorcycle garage)',
},
fit: fit, fit: fit,
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
loadingBuilder: (context, child, progress) { loadingBuilder: (context, child, progress) {

View File

@ -283,15 +283,18 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
.exportRide(r.id, asGpx: false); .exportRide(r.id, asGpx: false);
}, },
icon: const Icon(Icons.table_chart), icon: const Icon(Icons.table_chart),
label: Row( label: FittedBox(
mainAxisSize: MainAxisSize.min, fit: BoxFit.scaleDown,
children: [ child: Row(
const Text('Export CSV'), mainAxisSize: MainAxisSize.min,
if (!isProCsv) ...[ children: [
const SizedBox(width: 8), const Text('Export CSV'),
const ProBadge(), if (!isProCsv) ...[
const SizedBox(width: 8),
const ProBadge(),
],
], ],
], ),
), ),
); );
}, },

View File

@ -52,14 +52,17 @@ class EvolutionRideDetailsView extends ConsumerWidget {
icon: const Icon(Icons.arrow_back, color: Color(0xFF00E5FF)), icon: const Icon(Icons.arrow_back, color: Color(0xFF00E5FF)),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: Text( title: FittedBox(
'RIDE #${ride.id} TELEMETRY ARCHIVE', fit: BoxFit.scaleDown,
style: TextStyle( child: Text(
fontFamily: 'Inter', 'RIDE #${ride.id} TELEMETRY ARCHIVE',
fontSize: isLandscape ? 12 : 14, style: TextStyle(
fontWeight: FontWeight.w900, fontFamily: 'Inter',
color: Colors.white, fontSize: isLandscape ? 12 : 14,
letterSpacing: 0.1, fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.1,
),
), ),
), ),
actions: [ actions: [
@ -313,29 +316,32 @@ class EvolutionRideDetailsView extends ConsumerWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
// Section 4: Action Buttons // Section 4: Action Buttons
Row( LayoutBuilder(
children: [ builder: (context, constraints) {
Expanded( final isNarrow = constraints.maxWidth < 390;
child: OutlinedButton(
onPressed: () { Widget buildReplayButton() => OutlinedButton(
Navigator.of(context).push( onPressed: () {
MaterialPageRoute( Navigator.of(context).push(
builder: (_) => ReplayScreen(rideId: ride.id), 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),
), ),
);
},
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', 'REPLAY RIDE',
style: TextStyle( style: TextStyle(
fontFamily: 'Sora', fontFamily: 'Sora',
@ -344,26 +350,27 @@ class EvolutionRideDetailsView extends ConsumerWidget {
), ),
), ),
), ),
), );
const SizedBox(width: 8),
Expanded( Widget buildGpxButton() => OutlinedButton(
child: OutlinedButton( onPressed: () async {
onPressed: () async { final exporter = ref.read(exportServiceProvider);
final exporter = ref.read(exportServiceProvider); await exporter.exportRide(ride.id, asGpx: true);
await exporter.exportRide(ride.id, asGpx: true); },
}, style: OutlinedButton.styleFrom(
style: OutlinedButton.styleFrom( foregroundColor: Colors.white70,
foregroundColor: Colors.white70, side: const BorderSide(
side: const BorderSide( color: Color(0xFF333333),
color: Color(0xFF333333), width: 1.2,
width: 1.2,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
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', 'EXPORT GPX',
style: TextStyle( style: TextStyle(
fontFamily: 'Sora', fontFamily: 'Sora',
@ -372,40 +379,42 @@ class EvolutionRideDetailsView extends ConsumerWidget {
), ),
), ),
), ),
), );
const SizedBox(width: 8),
Expanded( Widget buildCsvButton() => Builder(
child: Builder( builder: (context) {
builder: (context) { final isProCsv = ref
final isProCsv = ref .watch(featureAccessProvider)
.watch(featureAccessProvider) .canAccess(MotorSignalFeature.csvExport);
.canAccess(MotorSignalFeature.csvExport); return OutlinedButton(
return OutlinedButton( onPressed: () async {
onPressed: () async { if (!isProCsv) {
if (!isProCsv) { showProUpgradeSheet(
showProUpgradeSheet( context,
context, ref,
ref, feature: MotorSignalFeature.csvExport,
feature: MotorSignalFeature.csvExport, );
); return;
return; }
} final exporter = ref.read(exportServiceProvider);
final exporter = ref.read(exportServiceProvider); await exporter.exportRide(ride.id, asGpx: false);
await exporter.exportRide(ride.id, asGpx: false); },
}, style: OutlinedButton.styleFrom(
style: OutlinedButton.styleFrom( foregroundColor: Colors.white70,
foregroundColor: Colors.white70, side: const BorderSide(
side: const BorderSide( color: Color(0xFF333333),
color: Color(0xFF333333), width: 1.2,
width: 1.2,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text( const Text(
'EXPORT CSV', '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), const SizedBox(height: 24),
@ -913,41 +951,50 @@ class EvolutionRideDetailsView extends ConsumerWidget {
String label2, String label2,
String value2, String value2,
) { ) {
return Row( Widget buildItem(String label, String value) {
children: [ return LayoutBuilder(
Expanded( builder: (context, constraints) {
child: Row( if (constraints.maxWidth < 135) {
mainAxisAlignment: MainAxisAlignment.spaceBetween, return Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text( mainAxisSize: MainAxisSize.min,
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,
children: [ children: [
Text( 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( style: const TextStyle(
fontFamily: 'Inter', fontFamily: 'Inter',
fontSize: 11, fontSize: 11,
@ -955,8 +1002,12 @@ class EvolutionRideDetailsView extends ConsumerWidget {
color: Colors.white54, color: Colors.white54,
), ),
), ),
Text( ),
value2, const SizedBox(width: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: const TextStyle( style: const TextStyle(
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
fontSize: 12, fontSize: 12,
@ -964,9 +1015,19 @@ class EvolutionRideDetailsView extends ConsumerWidget {
color: Colors.white, color: Colors.white,
), ),
), ),
], ),
), ],
), );
},
);
}
return Row(
children: [
Expanded(child: buildItem(label1, value1)),
if (label2.isNotEmpty) ...[
const SizedBox(width: 12),
Expanded(child: buildItem(label2, value2)),
], ],
], ],
); );

View File

@ -108,10 +108,14 @@ class CurrentPlanCard extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceBetween, alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 6,
children: [ children: [
Row( Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isPro ? Icons.verified : Icons.shield_outlined, isPro ? Icons.verified : Icons.shield_outlined,

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.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';
import 'road_alert_models.dart'; import 'road_alert_models.dart';
@ -206,10 +205,5 @@ final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
list.add(ref.watch(communityProviderInstance)); list.add(ref.watch(communityProviderInstance));
} }
// Include Debug Mock Provider only in debug mode
if (kDebugMode) {
list.add(ref.watch(mockProviderInstance));
}
return list; return list;
}); });

View File

@ -131,6 +131,16 @@ class MotorcycleImageService {
..._defaultDescriptors, ..._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<MotorcycleImage?> resolve({ Future<MotorcycleImage?> resolve({
required int year, required int year,
required String make, required String make,
@ -146,7 +156,38 @@ class MotorcycleImageService {
} }
try { 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<MotorcycleImage?> _searchWikimedia(
String query,
int year,
String make,
String model,
) async {
try {
final uri = Uri.https('commons.wikimedia.org', '/w/api.php', { final uri = Uri.https('commons.wikimedia.org', '/w/api.php', {
'action': 'query', 'action': 'query',
'format': 'json', 'format': 'json',
@ -164,19 +205,13 @@ class MotorcycleImageService {
final response = await http.get(uri, headers: const { final response = await http.get(uri, headers: const {
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': 'MotorSignal/1.0 (motorcycle garage image lookup)', 'User-Agent': 'MotorSignal/1.0 (motorcycle garage image lookup)',
}).timeout(const Duration(seconds: 8)); }).timeout(const Duration(seconds: 6));
if (response.statusCode != 200) { if (response.statusCode != 200) return null;
_cache[key] = null;
return null;
}
final json = jsonDecode(response.body) as Map<String, dynamic>; final json = jsonDecode(response.body) as Map<String, dynamic>;
final pages = (json['query']?['pages'] as Map?)?.values; final pages = (json['query']?['pages'] as Map?)?.values;
if (pages == null) { if (pages == null || pages.isEmpty) return null;
_cache[key] = null;
return null;
}
MotorcycleImage? best; MotorcycleImage? best;
var bestScore = -1; var bestScore = -1;
@ -211,7 +246,7 @@ class MotorcycleImageService {
if (title.contains(normalizedYear)) score += 4; if (title.contains(normalizedYear)) score += 4;
if (title.contains('motorcycle') || title.contains('motorbike')) score += 1; if (title.contains('motorcycle') || title.contains('motorbike')) score += 1;
if (score > bestScore) { if (score >= 4 && score > bestScore) {
bestScore = score; bestScore = score;
best = MotorcycleImage( best = MotorcycleImage(
imageUrl: thumb, imageUrl: thumb,
@ -225,12 +260,8 @@ class MotorcycleImageService {
); );
} }
} }
_cache[key] = best;
return best; return best;
} catch (error) { } catch (_) {
debugPrint('Motorcycle image lookup failed: $error');
_cache[key] = null;
return null; return null;
} }
} }

View File

@ -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);
});
}

View File

@ -70,8 +70,8 @@ void main() {
expect(find.text('136'), findsOneWidget); expect(find.text('136'), findsOneWidget);
expect(find.text('MPH'), findsOneWidget); expect(find.text('MPH'), findsOneWidget);
expect(find.text('FIXED'), findsOneWidget); expect(find.text('NO GPS'), findsOneWidget);
expect(find.text('HEADSET'), findsOneWidget); expect(find.text('HEADSET'), findsNothing);
expect(find.text('BRAKING'), findsOneWidget); expect(find.text('BRAKING'), findsOneWidget);
expect(find.text('CORNERING'), findsOneWidget); expect(find.text('CORNERING'), findsOneWidget);
expect(find.text('LEAN ANGLE'), findsOneWidget); expect(find.text('LEAN ANGLE'), findsOneWidget);

View File

@ -154,5 +154,19 @@ void main() {
expect(file.lengthSync(), greaterThan(1000), reason: 'Asset file $assetPath is empty or corrupt'); 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'));
});
}); });
} }

View File

@ -249,5 +249,31 @@ void main() {
); );
expect(find.text('MANAGE SUBSCRIPTION'), findsOneWidget); 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);
});
}); });
} }

View File

@ -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<int> 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);
});
}

View File

@ -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');
});
}