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 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,
),
),
],
),
),
],
),

View File

@ -138,21 +138,24 @@ class _ProScreenState extends ConsumerState<ProScreen> {
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<ProScreen> {
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<ProScreen> {
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<ProScreen> {
),
),
),
],
if (isCurrentPlan) ...[
const SizedBox(width: 8),
if (isCurrentPlan)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@ -559,7 +562,6 @@ class _ProScreenState extends ConsumerState<ProScreen> {
),
),
),
],
],
),
const SizedBox(height: 4),
@ -574,32 +576,39 @@ class _ProScreenState extends ConsumerState<ProScreen> {
),
),
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,
),
),
),
],
],
),
),
],
),

View File

@ -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),

View File

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

View File

@ -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<MotorcycleImage?>(
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) {

View File

@ -283,15 +283,18 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
.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(),
],
],
],
),
),
);
},

View File

@ -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)),
],
],
);

View File

@ -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,

View File

@ -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<List<RoadAlertProvider>>((ref) {
list.add(ref.watch(communityProviderInstance));
}
// Include Debug Mock Provider only in debug mode
if (kDebugMode) {
list.add(ref.watch(mockProviderInstance));
}
return list;
});

View File

@ -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<MotorcycleImage?> 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<MotorcycleImage?> _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<String, dynamic>;
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;
}
}

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('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);

View File

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

View File

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

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