Skip to content

Commit c3f65c3

Browse files
committed
refactor(ui): extract SheetHeader widget for modal bottom sheets
1 parent ef9e69b commit c3f65c3

4 files changed

Lines changed: 110 additions & 27 deletions

File tree

app/lib/ui/composer/client_commands.dart

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import '../../store/connection.dart';
1414
import '../../store/models.dart';
1515
import '../../store/store.dart';
1616
import '../../transport/protocol.dart';
17+
import '../widgets/sheet_header.dart';
1718

1819
typedef ClientCmdHandler =
1920
Future<void> Function(
@@ -273,17 +274,12 @@ const _thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
273274
Future<String?> _pickThinkingLevel(BuildContext context) {
274275
return showModalBottomSheet<String>(
275276
context: context,
277+
showDragHandle: true,
276278
builder: (sheetContext) => SafeArea(
277279
child: Column(
278280
mainAxisSize: MainAxisSize.min,
279281
children: [
280-
const ListTile(
281-
dense: true,
282-
title: Text(
283-
'Thinking level',
284-
style: TextStyle(fontWeight: FontWeight.bold),
285-
),
286-
),
282+
const SheetHeader(title: 'Thinking level'),
287283
for (final level in _thinkingLevels)
288284
ListTile(
289285
title: Text(level),
@@ -305,14 +301,12 @@ Future<ModelInfo?> _pickModel(
305301
return showModalBottomSheet<ModelInfo>(
306302
context: context,
307303
isScrollControlled: true,
304+
showDragHandle: true,
308305
builder: (sheetContext) => SafeArea(
309306
child: ListView(
310307
shrinkWrap: true,
311308
children: [
312-
const ListTile(
313-
dense: true,
314-
title: Text('Model', style: TextStyle(fontWeight: FontWeight.bold)),
315-
),
309+
const SheetHeader(title: 'Model'),
316310
for (final m in models)
317311
ListTile(
318312
title: Text(m.name),

app/lib/ui/home/home_screen.dart

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../../app/theme.dart' show kPinoBrandBlue;
88
import '../project/folder_browser.dart';
99
import '../widgets/connection_chip.dart';
1010
import '../widgets/glass.dart';
11+
import '../widgets/sheet_header.dart';
1112

1213
/// Brand blue accent used for running/active glass affordances (shared token).
1314
const _kBrandBlue = kPinoBrandBlue;
@@ -74,17 +75,12 @@ class HomeScreen extends ConsumerWidget {
7475
} else {
7576
target = await showModalBottomSheet<Project>(
7677
context: context,
78+
showDragHandle: true,
7779
builder: (ctx) => SafeArea(
7880
child: Column(
7981
mainAxisSize: MainAxisSize.min,
8082
children: [
81-
const Padding(
82-
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
83-
child: Text(
84-
'Spawn new session in…',
85-
style: TextStyle(fontWeight: FontWeight.w600),
86-
),
87-
),
83+
const SheetHeader(title: 'Spawn new session in…'),
8884
for (final p in projects)
8985
ListTile(
9086
leading: const Icon(Icons.folder_outlined),
@@ -303,19 +299,26 @@ class _AttachPastButton extends ConsumerWidget {
303299
showDragHandle: true,
304300
builder: (ctx) => SafeArea(
305301
child: metas.isEmpty
306-
? const Padding(
307-
padding: EdgeInsets.all(24),
308-
child: Text('No past sessions.', textAlign: TextAlign.center),
302+
? Column(
303+
mainAxisSize: MainAxisSize.min,
304+
children: [
305+
SheetHeader(
306+
title: 'Resume a past session in ${project.name}',
307+
),
308+
const Padding(
309+
padding: EdgeInsets.fromLTRB(24, 8, 24, 24),
310+
child: Text(
311+
'No past sessions.',
312+
textAlign: TextAlign.center,
313+
),
314+
),
315+
],
309316
)
310317
: ListView(
311318
shrinkWrap: true,
312319
children: [
313-
Padding(
314-
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
315-
child: Text(
316-
'Resume a past session in ${project.name}',
317-
style: const TextStyle(fontWeight: FontWeight.w600),
318-
),
320+
SheetHeader(
321+
title: 'Resume a past session in ${project.name}',
319322
),
320323
for (final m in metas)
321324
ListTile(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'package:flutter/material.dart';
2+
3+
/// A consistent header for list-style modal bottom sheets: a bold title plus
4+
/// an explicit close (✕) button that dismisses the sheet. Pair with
5+
/// `showDragHandle: true` so users always have a discoverable way to back out
6+
/// of a list without making a selection.
7+
class SheetHeader extends StatelessWidget {
8+
const SheetHeader({super.key, required this.title});
9+
10+
final String title;
11+
12+
@override
13+
Widget build(BuildContext context) {
14+
return Padding(
15+
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
16+
child: Row(
17+
children: [
18+
Expanded(
19+
child: Text(
20+
title,
21+
style: const TextStyle(fontWeight: FontWeight.w600),
22+
),
23+
),
24+
IconButton(
25+
icon: const Icon(Icons.close),
26+
tooltip: 'Close',
27+
onPressed: () => Navigator.of(context).pop(),
28+
),
29+
],
30+
),
31+
);
32+
}
33+
}

app/test/sheet_header_test.dart

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:pino/ui/widgets/sheet_header.dart';
4+
5+
void main() {
6+
testWidgets('renders the title and a close button', (tester) async {
7+
await tester.pumpWidget(
8+
const MaterialApp(
9+
home: Scaffold(body: SheetHeader(title: 'Model')),
10+
),
11+
);
12+
13+
expect(find.text('Model'), findsOneWidget);
14+
expect(find.byIcon(Icons.close), findsOneWidget);
15+
});
16+
17+
testWidgets('close button pops the sheet without a selection', (
18+
tester,
19+
) async {
20+
String? result = 'unset';
21+
22+
await tester.pumpWidget(
23+
MaterialApp(
24+
home: Scaffold(
25+
body: Builder(
26+
builder: (context) => ElevatedButton(
27+
onPressed: () async {
28+
result = await showModalBottomSheet<String>(
29+
context: context,
30+
builder: (_) => const Column(
31+
mainAxisSize: MainAxisSize.min,
32+
children: [SheetHeader(title: 'Pick one')],
33+
),
34+
);
35+
},
36+
child: const Text('open'),
37+
),
38+
),
39+
),
40+
),
41+
);
42+
43+
await tester.tap(find.text('open'));
44+
await tester.pumpAndSettle();
45+
expect(find.text('Pick one'), findsOneWidget);
46+
47+
await tester.tap(find.byIcon(Icons.close));
48+
await tester.pumpAndSettle();
49+
50+
expect(find.text('Pick one'), findsNothing); // sheet dismissed
51+
expect(result, isNull); // no selection returned
52+
});
53+
}

0 commit comments

Comments
 (0)