Fix/garage layout batch4 (#86)

* fix(garage): improve landscape layout (#62)

* fix(garage): make service dialog responsive (#63)

* fix(garage): truncate service type dropdown (#64)

* fix(garage): avoid compact landscape card overflow (#62)

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-18 02:10:52 -04:00 committed by GitHub
parent 3f64083bdd
commit 74943a8420
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 137 additions and 24 deletions

View File

@ -112,14 +112,17 @@ class GarageScreen extends ConsumerWidget {
if (isMultiColumn) { if (isMultiColumn) {
return GridView.builder( return GridView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 550, maxCrossAxisExtent: responsive.isShortViewport ? 600 : 550,
mainAxisExtent: 420, mainAxisExtent: responsive.isShortViewport ? 300 : 420,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
), ),
itemCount: bikes.length, itemCount: bikes.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == bikes.length) {
return _buildAddMotorcycleCard(context, ref, bikes);
}
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true); return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true);
}, },
); );
@ -127,8 +130,11 @@ class GarageScreen extends ConsumerWidget {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
itemCount: bikes.length, itemCount: bikes.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == bikes.length) {
return _buildAddMotorcycleCard(context, ref, bikes);
}
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false); 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'); context.push('/garage/add');
} }
Widget _buildAddMotorcycleCard(
BuildContext context,
WidgetRef ref,
List<GarageMotorcycle> 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( Widget _buildEmptyState(
BuildContext context, BuildContext context,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
@ -306,7 +320,7 @@ class GarageScreen extends ConsumerWidget {
year: bike.year, year: bike.year,
make: bike.makeName, make: bike.makeName,
model: bike.modelName, model: bike.modelName,
height: 150, height: isGrid ? 100 : 150,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@ -1207,17 +1207,29 @@ class MotorcycleDetailScreen extends ConsumerWidget {
builder: (context) { builder: (context) {
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
final size = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
return AlertDialog( return AlertDialog(
scrollable: true,
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: size.height - viewInsets.bottom - 48,
),
title: const Text('Log New Service'), title: const Text('Log New Service'),
content: SingleChildScrollView( content: Column(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: selectedType, initialValue: selectedType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Service Type'), 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) { onChanged: (val) {
if (val != null) setState(() => selectedType = val); if (val != null) setState(() => selectedType = val);
}, },
@ -1268,7 +1280,6 @@ class MotorcycleDetailScreen extends ConsumerWidget {
), ),
], ],
), ),
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton( ElevatedButton(
@ -1340,8 +1351,14 @@ class MotorcycleDetailScreen extends ConsumerWidget {
children: [ children: [
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: selectedType, initialValue: selectedType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Service Type'), 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) { onChanged: (val) {
if (val != null) setState(() => selectedType = val); if (val != null) setState(() => selectedType = val);
}, },

View File

@ -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/garage_screen.dart';
import 'package:motorcycle_dash/features/garage/motorcycle_detail_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/add_motorcycle_screen.dart';
import 'package:motorcycle_dash/features/garage/widgets/motorcycle_photo.dart';
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService { class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
@override @override
@ -340,5 +341,86 @@ void main() {
await tester.pump(const Duration(milliseconds: 100)); 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<GridView>(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<DropdownButton<String>>(find.byType(DropdownButton<String>).first);
expect(dropdown.isExpanded, isTrue);
final label = tester.widget<Text>(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));
});
});
} }