Skip to content

Commit bd36731

Browse files
committed
[red] cover iPad keyboard palette geometry fallback
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent e6e6f94 commit bd36731

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
import 'package:flutter/material.dart';
5+
import 'package:flutter/services.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:pyble/console/console.dart';
8+
import 'package:pyble/pble/pble.dart';
9+
10+
import '../support/shell_harness.dart';
11+
12+
const MethodChannel _channel = MethodChannel('dev.pyble/keyboard/insets');
13+
14+
void main() {
15+
final List<MethodCall> calls = <MethodCall>[];
16+
setUp(() {
17+
calls.clear();
18+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
19+
.setMockMethodCallHandler(_channel, (MethodCall call) async {
20+
calls.add(call);
21+
return null;
22+
});
23+
});
24+
tearDown(() {
25+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
26+
.setMockMethodCallHandler(_channel, null);
27+
});
28+
29+
int generation() =>
30+
(calls.lastWhere((MethodCall call) => call.method == 'observe').arguments
31+
as int);
32+
33+
Future<void> report(WidgetTester tester, double bottom, {int? epoch}) async {
34+
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
35+
_channel.name,
36+
_channel.codec.encodeMethodCall(
37+
MethodCall('inset', <String, Object>{
38+
'generation': epoch ?? generation(),
39+
'bottom': bottom,
40+
}),
41+
),
42+
(_) {},
43+
);
44+
await tester.pump();
45+
}
46+
47+
MediaQueryData geometry(WidgetTester tester) =>
48+
MediaQuery.of(tester.element(find.byType(Scaffold).first));
49+
50+
testWidgets(
51+
'iPad palette reopening keeps Expand above the actual keyboard',
52+
(WidgetTester tester) async {
53+
await pumpShell(
54+
tester,
55+
connection: fakeConnection(initial: ConnState.ready),
56+
);
57+
expect(
58+
calls.where((MethodCall call) => call.method == 'observe'),
59+
isNotEmpty,
60+
);
61+
final EditableText editor = tester.widget(
62+
find.byType(EditableText).first,
63+
);
64+
editor.controller.text = '# retained across native geometry';
65+
editor.controller.selection = const TextSelection.collapsed(offset: 5);
66+
await report(tester, 81.5);
67+
expect(geometry(tester).viewInsets.bottom, 81.5);
68+
await report(tester, 498);
69+
expect(geometry(tester).viewInsets.bottom, 498);
70+
expect(
71+
tester.getRect(find.byKey(kConsoleStripToggleKey)).bottom,
72+
lessThanOrEqualTo(1024 - 498),
73+
);
74+
expect(find.byKey(kConsoleStripToggleKey).hitTestable(), findsOneWidget);
75+
expect(editor.controller.text, '# retained across native geometry');
76+
expect(
77+
editor.controller.selection,
78+
const TextSelection.collapsed(offset: 5),
79+
);
80+
expect(tester.takeException(), isNull);
81+
},
82+
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
83+
);
84+
85+
testWidgets(
86+
'guide and Flutter insets combine by maximum and restore padding',
87+
(WidgetTester tester) async {
88+
await pumpShell(
89+
tester,
90+
connection: fakeConnection(initial: ConnState.ready),
91+
);
92+
tester.view.viewPadding = const FakeViewPadding(bottom: 40);
93+
tester.view.padding = const FakeViewPadding(bottom: 40);
94+
tester.view.viewInsets = const FakeViewPadding(bottom: 996);
95+
addTearDown(tester.view.resetViewPadding);
96+
addTearDown(tester.view.resetPadding);
97+
addTearDown(tester.view.resetViewInsets);
98+
await tester.pump();
99+
await report(tester, 498);
100+
expect(geometry(tester).viewInsets.bottom, 498);
101+
expect(geometry(tester).padding.bottom, 0);
102+
await report(tester, 81.5);
103+
expect(geometry(tester).viewInsets.bottom, 498);
104+
tester.view.resetViewInsets();
105+
await tester.pump();
106+
expect(geometry(tester).viewInsets.bottom, 81.5);
107+
await report(tester, 0);
108+
expect(geometry(tester).viewInsets.bottom, 0);
109+
expect(geometry(tester).padding.bottom, 20);
110+
},
111+
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
112+
);
113+
114+
testWidgets(
115+
'background resets observation and stale or invalid reports are inert',
116+
(WidgetTester tester) async {
117+
await pumpShell(
118+
tester,
119+
connection: fakeConnection(initial: ConnState.ready),
120+
);
121+
final int old = generation();
122+
await report(tester, 498);
123+
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
124+
await tester.pump();
125+
expect(geometry(tester).viewInsets.bottom, 0);
126+
await report(tester, 498, epoch: old);
127+
expect(geometry(tester).viewInsets.bottom, 0);
128+
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
129+
await tester.pump();
130+
expect(generation(), isNot(old));
131+
await report(tester, 498, epoch: old);
132+
await report(tester, -1);
133+
await report(tester, double.infinity);
134+
await report(tester, 2048);
135+
expect(geometry(tester).viewInsets.bottom, 0);
136+
await report(tester, 70.5);
137+
expect(geometry(tester).viewInsets.bottom, 70.5);
138+
await tester.pumpWidget(const SizedBox());
139+
expect(calls.last.method, 'stop');
140+
},
141+
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
142+
);
143+
144+
testWidgets(
145+
'Android retains ordinary Flutter keyboard geometry',
146+
(WidgetTester tester) async {
147+
await pumpShell(
148+
tester,
149+
connection: fakeConnection(initial: ConnState.ready),
150+
);
151+
expect(calls, isEmpty);
152+
tester.view.viewInsets = const FakeViewPadding(bottom: 808);
153+
addTearDown(tester.view.resetViewInsets);
154+
await tester.pump();
155+
expect(geometry(tester).viewInsets.bottom, 404);
156+
},
157+
variant: TargetPlatformVariant.only(TargetPlatform.android),
158+
);
159+
}

‎docs/specifications/App/TDD.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2048,6 +2048,19 @@ Tablet-first, responsive (CON-5, FR-UI-5). The shell is **connection-gated** ([A
20482048
stays collapsed until actual view insets clear. This action does not clear
20492049
a text client, replace the Blocks host, discard buffered output, or mutate
20502050
the document/selection.
2051+
- **iOS keyboard geometry (FR-UI-8).** Observe the current engine view's public
2052+
`UIKeyboardLayoutGuide` after the first Flutter frame and when the app resumes
2053+
or view metrics change. A noninteractive view constrained to the guide reports
2054+
docked keyboard/palette overlap; a hidden guide's safe-area-only overlap is
2055+
zero. Do not infer visibility from notification end frames: physical iPad
2056+
Hide → palette Show Keyboard can report an offscreen end frame while the
2057+
guide correctly intersects the view. The application-level MediaQuery uses
2058+
`max(Flutter bottom inset, guide overlap)` and recalculates bottom padding
2059+
from viewPadding. Observation belongs to the current engine/view, stops on
2060+
detach/background, rejects stale generations and invalid geometry, and never
2061+
recreates the editor or Blocks host. Regression tests cover zero/stale Flutter
2062+
insets, already-correct insets without double shrink, palette retention,
2063+
safe-area restoration, stale callbacks, resume and ordinary non-iOS layout.
20512064
- **Portrait / stacked tablet** layout uses a bottom bar to swap the full-width
20522065
Editor, Console, Files, and Blocks work surfaces (FR-UI-2,
20532066
[PRD §19.2](../prd.md)); editor remains the default primary surface. Blocks is

‎docs/specifications/App/specs.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,7 @@ implementation:
10121012
- **FR-UI-6** — Interactive controls MUST use large touch targets and MUST clearly communicate connection and run state (disconnected/connecting/ready/running/error). MUST (*source: PRD §13.2; verify: golden; story: A-22*)
10131013
- **FR-UI-7** — No UI panel MUST bind to BLE directly; every panel binds to `Connection`. MUST (*source: PRD §19, app.md §1; verify: unit; story: all A-2x/3x*)
10141014
- **FR-UI-8** — With a landscape software keyboard open, the connected shell MUST retain a usable editor viewport and reachable navigation without panel overflow. The console output strip MUST temporarily collapse to its status/actions bar while retaining buffered output; expanding it dismisses the keyboard. Navigation destinations MUST remain reachable by scrolling when height is constrained. Closing the keyboard restores the ordinary console layout and preserves the current document and selection. MUST (*source: FR-UI-1/5/6, NFR-A11Y-3; verify: widget and physical keyboard observations*)
1015+
On iOS, this includes reopening the docked keyboard through its input palette when notification-derived Flutter insets are stale. The current window's keyboard layout guide supplies a fallback bottom occlusion; combine it with Flutter's inset by maximum, never addition. Preserve a visible input palette's actual occlusion and ordinary safe-area padding when the keyboard is hidden.
10151016

10161017
### 4.14 Scan/Connect Flow UI (`lib/connect/`) — FR-CONNECT
10171018

0 commit comments

Comments
 (0)