diff --git a/lib/features/garage/garage_screen.dart b/lib/features/garage/garage_screen.dart index e5915ca..290cef1 100644 --- a/lib/features/garage/garage_screen.dart +++ b/lib/features/garage/garage_screen.dart @@ -112,14 +112,17 @@ class GarageScreen extends ConsumerWidget { if (isMultiColumn) { return GridView.builder( padding: const EdgeInsets.all(16.0), - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 550, - mainAxisExtent: 420, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: responsive.isShortViewport ? 600 : 550, + mainAxisExtent: responsive.isShortViewport ? 300 : 420, crossAxisSpacing: 16, mainAxisSpacing: 16, ), - itemCount: bikes.length, + itemCount: bikes.length + 1, itemBuilder: (context, index) { + if (index == bikes.length) { + return _buildAddMotorcycleCard(context, ref, bikes); + } return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true); }, ); @@ -127,8 +130,11 @@ class GarageScreen extends ConsumerWidget { return ListView.builder( padding: const EdgeInsets.all(16.0), - itemCount: bikes.length, + itemCount: bikes.length + 1, itemBuilder: (context, index) { + if (index == bikes.length) { + return _buildAddMotorcycleCard(context, ref, bikes); + } return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false); }, ); @@ -136,19 +142,6 @@ class GarageScreen extends ConsumerWidget { ); }, ), - floatingActionButton: bikesAsync.value != null && bikesAsync.value!.isNotEmpty - ? FloatingActionButton.extended( - key: const ValueKey('garage_add_motorcycle_fab'), - backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, - icon: const Icon(Icons.two_wheeler), - label: const Text( - 'Add Motorcycle', - style: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0.2), - ), - onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value), - ) - : null, ); } @@ -173,6 +166,27 @@ class GarageScreen extends ConsumerWidget { context.push('/garage/add'); } + Widget _buildAddMotorcycleCard( + BuildContext context, + WidgetRef ref, + List bikes, + ) { + final colors = context.motorColors; + return Card( + child: Center( + child: OutlinedButton.icon( + icon: const Icon(Icons.add), + label: const Text('Add Motorcycle'), + style: OutlinedButton.styleFrom( + foregroundColor: colors.cyberCyan, + side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.6)), + ), + onPressed: () => _onAddMotorcycle(context, ref, bikes), + ), + ), + ); + } + Widget _buildEmptyState( BuildContext context, MotorSignalThemeColors colors, @@ -306,7 +320,7 @@ class GarageScreen extends ConsumerWidget { year: bike.year, make: bike.makeName, model: bike.modelName, - height: 150, + height: isGrid ? 100 : 150, borderRadius: BorderRadius.circular(10), ), const SizedBox(height: 12), diff --git a/lib/features/garage/motorcycle_detail_screen.dart b/lib/features/garage/motorcycle_detail_screen.dart index 54cd9c1..74ffd0a 100644 --- a/lib/features/garage/motorcycle_detail_screen.dart +++ b/lib/features/garage/motorcycle_detail_screen.dart @@ -1207,17 +1207,29 @@ class MotorcycleDetailScreen extends ConsumerWidget { builder: (context) { return StatefulBuilder( builder: (context, setState) { + final size = MediaQuery.sizeOf(context); + final viewInsets = MediaQuery.viewInsetsOf(context); return AlertDialog( + scrollable: true, + constraints: BoxConstraints( + maxWidth: 560, + maxHeight: size.height - viewInsets.bottom - 48, + ), title: const Text('Log New Service'), - content: SingleChildScrollView( - child: Column( + content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( initialValue: selectedType, + isExpanded: true, decoration: const InputDecoration(labelText: 'Service Type'), - items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), + items: serviceTypes + .map((t) => DropdownMenuItem( + value: t, + child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis), + )) + .toList(), onChanged: (val) { if (val != null) setState(() => selectedType = val); }, @@ -1268,7 +1280,6 @@ class MotorcycleDetailScreen extends ConsumerWidget { ), ], ), - ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), ElevatedButton( @@ -1340,8 +1351,14 @@ class MotorcycleDetailScreen extends ConsumerWidget { children: [ DropdownButtonFormField( initialValue: selectedType, + isExpanded: true, decoration: const InputDecoration(labelText: 'Service Type'), - items: serviceTypes.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), + items: serviceTypes + .map((t) => DropdownMenuItem( + value: t, + child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis), + )) + .toList(), onChanged: (val) { if (val != null) setState(() => selectedType = val); }, diff --git a/test/canonical_history_records_garage_responsive_test.dart b/test/canonical_history_records_garage_responsive_test.dart index 458489d..e4e66fa 100644 --- a/test/canonical_history_records_garage_responsive_test.dart +++ b/test/canonical_history_records_garage_responsive_test.dart @@ -18,6 +18,7 @@ import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:motorcycle_dash/features/garage/garage_screen.dart'; import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart'; import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart'; +import 'package:motorcycle_dash/features/garage/widgets/motorcycle_photo.dart'; class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService { @override @@ -340,5 +341,86 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); }); }); + + group('Garage responsive regression coverage', () { + testWidgets('BUG-015: landscape garage card and Add Motorcycle remain reachable', (tester) async { + tester.view.physicalSize = const Size(800, 360); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestApp(const GarageScreen())); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final grid = tester.widget(find.byType(GridView)); + final delegate = grid.gridDelegate as SliverGridDelegateWithMaxCrossAxisExtent; + expect(delegate.mainAxisExtent, lessThanOrEqualTo(320)); + expect(tester.getSize(find.byType(MotorcyclePhoto).first).height, lessThanOrEqualTo(120)); + final addMotorcycle = find.text('Add Motorcycle'); + await tester.ensureVisible(addMotorcycle); + expect(addMotorcycle.hitTestable(), findsOneWidget); + + await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('SPECIFICATIONS')); + expect(tester.takeException(), isNull); + expect(tester.getTopLeft(find.text('SPECIFICATIONS')).dy, lessThan(360)); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 100)); + }); + + for (final keyboardInset in [0.0, 180.0]) { + testWidgets('BUG-016: landscape service dialog remains usable with keyboard inset $keyboardInset', (tester) async { + tester.view.physicalSize = const Size(800, 360); + tester.view.devicePixelRatio = 1.0; + tester.view.viewInsets = FakeViewPadding(bottom: keyboardInset); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetViewInsets(); + }); + + await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Log Service')); + await tester.tap(find.text('Log Service')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Log New Service'), findsOneWidget); + final notesLabel = find.text('Notes (e.g. Brand, weight, shop)'); + await tester.ensureVisible(notesLabel); + expect(tester.getBottomRight(notesLabel).dy, lessThan(360 - keyboardInset)); + expect(find.text('Log New Service').hitTestable(), findsNothing); + expect(find.text('Cancel').hitTestable(), findsOneWidget); + expect(find.text('Save Record').hitTestable(), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Log New Service'), findsNothing); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 100)); + }); + } + + testWidgets('BUG-017: narrow portrait service type dropdown truncates label without overflow', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1))); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Log Service')); + await tester.tap(find.text('Log Service')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final dropdown = tester.widget>(find.byType(DropdownButton).first); + expect(dropdown.isExpanded, isTrue); + final label = tester.widget(find.text('Engine Oil & Filter').first); + expect(label.maxLines, 1); + expect(label.overflow, TextOverflow.ellipsis); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 100)); + }); + }); }