fix: resolve dashboard and responsive UI defects
This commit is contained in:
parent
173c99d450
commit
121be97ecc
|
|
@ -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,59 +70,63 @@ 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
color: isRecording
|
||||
? const Color(0xFF00E5FF).withValues(alpha: 0.15)
|
||||
: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.white24, width: 0.8),
|
||||
border: Border.all(
|
||||
color: isRecording
|
||||
? const Color(0xFF00E5FF)
|
||||
: Colors.white24,
|
||||
width: 0.8,
|
||||
),
|
||||
child: const Text(
|
||||
'92%',
|
||||
),
|
||||
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: 11,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
color: isRecording
|
||||
? const Color(0xFF00E5FF)
|
||||
: Colors.white70,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -138,7 +138,9 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Row(
|
||||
title: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
|
|
@ -154,6 +156,7 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
ProBadge(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _currentStep != ProPurchaseStep.idle
|
||||
|
|
@ -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,
|
||||
|
|
@ -560,7 +563,6 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
|
|
@ -574,10 +576,15 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 130),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
priceDisplay,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
|
|
@ -588,6 +595,7 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isCurrentPlan ? 'Active' : 'Select',
|
||||
|
|
@ -601,6 +609,7 @@ class _ProScreenState extends ConsumerState<ProScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -173,11 +173,35 @@ class EvolutionMotorcycleDetailView extends ConsumerWidget {
|
|||
}
|
||||
|
||||
Widget _buildBigKpi(String value, String unit) {
|
||||
return Column(
|
||||
return Expanded(
|
||||
child: 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))),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -283,7 +283,9 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
|
|||
.exportRide(r.id, asGpx: false);
|
||||
},
|
||||
icon: const Icon(Icons.table_chart),
|
||||
label: Row(
|
||||
label: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Export CSV'),
|
||||
|
|
@ -293,6 +295,7 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
|
|||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
icon: const Icon(Icons.arrow_back, color: Color(0xFF00E5FF)),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
title: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'RIDE #${ride.id} TELEMETRY ARCHIVE',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
|
|
@ -62,6 +64,7 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share, color: Color(0xFF00E5FF)),
|
||||
|
|
@ -313,10 +316,11 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
const SizedBox(height: 24),
|
||||
|
||||
// Section 4: Action Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isNarrow = constraints.maxWidth < 390;
|
||||
|
||||
Widget buildReplayButton() => OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
|
|
@ -335,7 +339,9 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'REPLAY RIDE',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
|
|
@ -344,10 +350,9 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
);
|
||||
|
||||
Widget buildGpxButton() => OutlinedButton(
|
||||
onPressed: () async {
|
||||
final exporter = ref.read(exportServiceProvider);
|
||||
await exporter.exportRide(ride.id, asGpx: true);
|
||||
|
|
@ -363,7 +368,9 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'EXPORT GPX',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
|
|
@ -372,10 +379,9 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Builder(
|
||||
);
|
||||
|
||||
Widget buildCsvButton() => Builder(
|
||||
builder: (context) {
|
||||
final isProCsv = ref
|
||||
.watch(featureAccessProvider)
|
||||
|
|
@ -404,8 +410,11 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
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,60 +951,83 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
String label2,
|
||||
String value2,
|
||||
) {
|
||||
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(
|
||||
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,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: buildItem(label1, value1)),
|
||||
if (label2.isNotEmpty) ...[
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label2,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value2,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: buildItem(label2, value2)),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue