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