Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions lib/core/widgets/mnemonic_keyboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,16 @@ class MnemonicKeyboard extends StatelessWidget {
/// is already empty, so there is nothing to delete.
final bool canBackspace;

/// Whether the enter key is active. False on a half-typed prefix — only the
/// owner knows whether the word has resolved.
final bool canAdvance;

final void Function(String letter) onLetter;
final VoidCallback onBackspace;

/// Moves to the next field. Never completes or chooses a word.
final VoidCallback onEnter;

/// Paranoid mode toggle, shown as a key next to backspace.
final bool shuffleActive;
final VoidCallback onToggleShuffle;
Expand All @@ -53,8 +60,10 @@ class MnemonicKeyboard extends StatelessWidget {
super.key,
required this.enabledLetters,
required this.canBackspace,
required this.canAdvance,
required this.onLetter,
required this.onBackspace,
required this.onEnter,
required this.shuffleActive,
required this.onToggleShuffle,
required this.shuffleHint,
Expand Down Expand Up @@ -87,28 +96,30 @@ class MnemonicKeyboard extends StatelessWidget {
enabledLetters: enabledLetters,
onLetter: onLetter,
paranoid: shuffleActive,
trailing: _BackspaceKey(
enabled: canBackspace,
onTap: onBackspace,
),
),
_LetterRow(
letters: layout.sublist(19, 26),
enabledLetters: enabledLetters,
onLetter: onLetter,
paranoid: shuffleActive,
// Backspace shares its slot with the shuffle toggle
trailingKeys: 2,
// The shuffle toggle shares its slot with enter
trailing: Row(
children: [
Expanded(
child: _BackspaceKey(
enabled: canBackspace,
onTap: onBackspace,
),
),
Expanded(
child: _ShuffleKey(
active: shuffleActive,
hint: shuffleHint,
onTap: onToggleShuffle,
),
),
Expanded(
child: _EnterKey(enabled: canAdvance, onTap: onEnter),
),
],
),
),
Expand All @@ -127,12 +138,16 @@ class _LetterRow extends StatelessWidget {
final bool paranoid;
final Widget? trailing;

/// How many keys [trailing] lays out, used to size its slot.
final int trailingKeys;

const _LetterRow({
required this.letters,
required this.enabledLetters,
required this.onLetter,
required this.paranoid,
this.trailing,
this.trailingKeys = 1,
});

@override
Expand All @@ -150,7 +165,8 @@ class _LetterRow extends StatelessWidget {
onTap: () => onLetter(letter),
),
),
if (trailing != null) Expanded(flex: 2, child: trailing!),
// One unit per key, so trailing keys stay a letter wide.
if (trailing != null) Expanded(flex: trailingKeys, child: trailing!),
],
),
);
Expand Down Expand Up @@ -215,6 +231,34 @@ class _BackspaceKey extends StatelessWidget {
}
}

/// Moves to the next field, and nothing else.
///
/// It must never accept a suggestion: the chips are shuffled in paranoid mode,
/// so the first one is arbitrary, and no shortcut is worth writing a word the
/// user did not choose into a recovery phrase.
class _EnterKey extends StatelessWidget {
final bool enabled;
final VoidCallback onTap;

const _EnterKey({required this.enabled, required this.onTap});

@override
Widget build(BuildContext context) {
return _KeyCap(
key: const Key('mnemonicEnterKey'),
enabled: enabled,
onTap: onTap,
child: Icon(
Icons.keyboard_return,
size: 20,
color: enabled
? context.appColors.onSurface
: context.appColors.textMuted,
),
);
}
}

/// Toggles the paranoid, randomised-layout mode. Accent-coloured while active.
class _ShuffleKey extends StatelessWidget {
final bool active;
Expand Down
31 changes: 31 additions & 0 deletions lib/core/widgets/mnemonic_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -805,8 +805,10 @@ class _MnemonicSentenceWidgetState extends State<MnemonicSentenceWidget> {
language: widget.language,
),
canBackspace: prefix.isNotEmpty,
canAdvance: _canLeaveField(prefix),
onLetter: _onKeyLetter,
onBackspace: _onKeyBackspace,
onEnter: _onKeyEnter,
shuffleActive: _paranoid.value,
onToggleShuffle: _toggleParanoid,
shuffleHint: context.loc.mnemonicShuffleKeyboardHint,
Expand All @@ -821,6 +823,35 @@ class _MnemonicSentenceWidgetState extends State<MnemonicSentenceWidget> {
}
}

/// Whether [word] is a wordlist entry rather than a prefix of one.
///
/// Against the whole wordlist even on the last field: narrowing to the
/// checksum candidates would swallow a transcription error, as
/// [_maybeAutoFill] explains.
bool _isWholeWord(String word) => widget.language.list.contains(word);

/// A field can be left once it is finished, or if it was never started. A
/// half-typed prefix is neither: leaving it parks a fragment that reads like
/// a word.
bool _canLeaveField(String word) => word.isEmpty || _isWholeWord(word);

/// The keyboard's counterpart to tapping the next field. Never writes to the
/// sentence.
///
/// Re-checks the live text like [_onKeyLetter] does: `enabled` is captured at
/// build time, so a tap racing that frame would still land here.
void _onKeyEnter() {
final index = _activeField.value;
if (index == null) return;
if (!_canLeaveField(widget.controllers[index].text.trim())) return;
// Last word: dismiss rather than wrap, like the auto fill and a chip tap.
if (index == widget.controllers.length - 1) {
_focusNodes[index].unfocus();
} else {
_focusNext(index + 1);
}
}

void _onHintTap(int index, String word) {
_setWord(index, word);
// Like the auto fill, a tapped chip completes the sentence on the last
Expand Down
31 changes: 31 additions & 0 deletions test/core_test/widgets/mnemonic_keyboard_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ Future<void> pumpKeyboard(
WidgetTester tester, {
required Set<String> enabledLetters,
bool canBackspace = true,
bool canAdvance = true,
void Function(String)? onLetter,
VoidCallback? onBackspace,
VoidCallback? onEnter,
VoidCallback? onToggleShuffle,
}) async {
await tester.pumpWidget(
Expand All @@ -19,8 +21,10 @@ Future<void> pumpKeyboard(
body: MnemonicKeyboard(
enabledLetters: enabledLetters,
canBackspace: canBackspace,
canAdvance: canAdvance,
onLetter: onLetter ?? (_) {},
onBackspace: onBackspace ?? () {},
onEnter: onEnter ?? () {},
shuffleActive: false,
onToggleShuffle: onToggleShuffle ?? () {},
shuffleHint: 'shuffle',
Expand Down Expand Up @@ -92,5 +96,32 @@ void main() {
);
expect(fired, isFalse);
});

testWidgets('enter reports when enabled', (tester) async {
var fired = false;
await pumpKeyboard(
tester,
enabledLetters: const {},
onEnter: () => fired = true,
);

await tester.tap(find.byIcon(Icons.keyboard_return));
expect(fired, isTrue);
});

testWidgets('enter does nothing while the word is unfinished', (
tester,
) async {
var fired = false;
await pumpKeyboard(
tester,
enabledLetters: const {},
canAdvance: false,
onEnter: () => fired = true,
);

await tester.tap(find.byIcon(Icons.keyboard_return), warnIfMissed: false);
expect(fired, isFalse);
});
});
}
64 changes: 64 additions & 0 deletions test/core_test/widgets/mnemonic_widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ Finder _backspaceKey() => find.descendant(

Finder shuffleToggle() => find.byKey(const Key('mnemonicParanoidToggle'));

Finder enterKey() => find.byKey(const Key('mnemonicEnterKey'));

/// Taps enter, tolerating a disabled key so a test can assert it did nothing.
Future<void> tapEnter(WidgetTester tester) async {
await tester.tap(enterKey(), warnIfMissed: false);
await tester.pumpAndSettle();
}

/// The on-screen centre of every letter key currently rendered, keyed by
/// letter — used to detect that the layout changed (reshuffled) or held still.
Map<String, Offset> keyPositions(WidgetTester tester) {
Expand Down Expand Up @@ -652,6 +660,62 @@ void main() {
expect(find.text('128 possible last words'), findsOneWidget);
});

testWidgets('enter moves to the next word once the word is whole', (
tester,
) async {
await pumpWidget(tester, onSubmit: (_) {});
await typeWord(tester, 0, 'raise');

await tapEnter(tester);

expect(fieldHasFocus(tester, 1), isTrue);
expect(fieldText(tester, 0), equals('raise'));
});

testWidgets('enter skips an untouched field', (tester) async {
await pumpWidget(tester, onSubmit: (_) {});
await focusField(tester, 1);

await tapEnter(tester);
expect(fieldHasFocus(tester, 2), isTrue);

// Repeatable, so a run of empty fields can be stepped through.
await tapEnter(tester);
expect(fieldHasFocus(tester, 3), isTrue);
expect(fieldText(tester, 1), isEmpty);
expect(fieldText(tester, 2), isEmpty);
});

testWidgets('enter holds the field while the word is a prefix', (
tester,
) async {
await pumpWidget(tester, onSubmit: (_) {});
// 'rai' still matches raise/rail/rain: nothing has been decided yet.
await typeWord(tester, 0, 'rai');

await tapEnter(tester);

expect(fieldHasFocus(tester, 0), isTrue);
expect(
fieldText(tester, 0),
equals('rai'),
reason: 'enter must never pick a word on the user behalf',
);
expect(fieldText(tester, 1), isEmpty);
});

testWidgets('enter on the last field dismisses the keyboard', (
tester,
) async {
await pumpWidget(tester, onSubmit: (_) {});
await fillAll(tester, validWords);

await focusField(tester, 11);
await tapEnter(tester);

expect(fieldHasFocus(tester, 11), isFalse);
});

testWidgets('tapping a chip on the last field dismisses the keyboard', (
tester,
) async {
Expand Down
Loading