Files
buzz/mobile/lib/features/pairing/pairing_page/pairing_code_entry.dart
klopez4212 5173fad660 feat(pairing): enter desktop codes on mobile (#8085)
## Summary
Enter a desktop-only, random six-digit code on mobile to pair without a
desktop confirmation click. Verification uses the encrypted session,
limits guesses to five, and never treats the QR-derived transcript as
approval. Older clients retain desktop confirmation.

Adds digit-only error shakes and native haptics, a separate biometrics
choice, and a direct community-icon arrival animation. Fixes dropped
early offers and resets completed pairing so reopening or signing out
cannot get stuck.

### Related issue
Related: #8060; no duplicate code-entry PR found.
<img width="780" height="1688" alt="01-enter-code"
src="https://github.com/user-attachments/assets/a99ced3e-4305-4ac6-a6cc-d598ced423e7"
/>
<img width="780" height="1688" alt="02-incorrect-code"
src="https://github.com/user-attachments/assets/19cdb109-1f10-40a9-a661-1d7ba89c81c1"
/>
<img width="780" height="1688" alt="03-biometrics"
src="https://github.com/user-attachments/assets/af1b2924-9ab6-4d1a-b4b1-fccfdf446404"
/>
<img width="780" height="1688" alt="04-community-loading"
src="https://github.com/user-attachments/assets/84145d82-843a-4c25-aef7-6492fc241d4b"
/>
<img width="897" height="388" alt="05-desktop-code"
src="https://github.com/user-attachments/assets/d8a2e469-7e42-466a-988f-bab6b1df85fc"
/>

### Release order
Deploy the updated pairing relay first, then desktop, then mobile. The
relay now allows eight events per connection (seven for a successful
fifth guess plus room for cancellation), with a corresponding bounded
delivery budget. Existing clients remain supported; older client
combinations keep desktop confirmation.

### Testing
- 103 mobile pairing tests, 270 core tests, 51 relay integration tests,
and the new real-relay fifth-guess flow passed.
- The real-relay regression verifies proof, decrypted identity import,
and completion after four wrong guesses. Restoring the old six-event cap
makes it fail.
- A paused-clock desktop regression confirms slow readiness cannot
extend the visible QR beyond protocol expiry. Mobile regression covers
typing before negotiation completes.
- Core/pair-relay Clippy and full native desktop Clippy/workspace tests
passed. Mobile analysis passed; broad mobile runs each hit an unrelated
failure (a gateway-script timeout and a native-menu missing-plugin
error), and both affected test files passed separately.
- Repository-wide Rust testing exhausted local disk space while
compiling unrelated packages. The final push omitted that
resource-blocked hook and avoided repeating the desktop/mobile runs
above; hosted CI remains required.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-05 13:42:39 -04:00

167 lines
6.7 KiB
Dart

part of '../pairing_page.dart';
/// One native editing buffer keeps paste, deletion and accessibility reliable;
/// the six visual cells mirror the desktop verification input.
class _PairingCodeEntry extends HookConsumerWidget {
const _PairingCodeEntry({
required this.controller,
required this.enabled,
required this.invalid,
required this.onChanged,
required this.onSubmitted,
required this.onRejectedInput,
});
final TextEditingController controller;
final bool enabled;
final bool invalid;
final ValueChanged<String> onChanged;
final VoidCallback onSubmitted;
final VoidCallback onRejectedInput;
@override
Widget build(BuildContext context, WidgetRef ref) {
final focus = useFocusNode();
useListenable(focus);
final editing = useValueListenable(controller);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final activeIndex = editing.selection.isValid
? editing.selection.start.clamp(0, 5)
: editing.text.length.clamp(0, 5);
void focusCell(int index) {
if (!enabled) return;
focus.requestFocus();
final offset = index.clamp(0, editing.text.length);
controller.selection = offset < editing.text.length
? TextSelection(baseOffset: offset, extentOffset: offset + 1)
: TextSelection.collapsed(offset: offset);
}
return Stack(
alignment: Alignment.center,
children: [
// The real field remains available to assistive technology and the
// keyboard. Visual cells handle pointer placement and display only.
Positioned.fill(
child: Opacity(
opacity: 0,
alwaysIncludeSemantics: true,
child: TextField(
key: const Key('pairing-code-input'),
controller: controller,
focusNode: focus,
enabled: enabled,
autofocus: true,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.oneTimeCode],
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
TextInputFormatter.withFunction((oldValue, newValue) {
final limited = LengthLimitingTextInputFormatter(
6,
).formatEditUpdate(oldValue, newValue);
// TextField does not call onChanged when the length limit
// rejects another digit. Still acknowledge that attempt.
if (invalid &&
newValue.text.length > oldValue.text.length &&
limited.text == oldValue.text) {
onRejectedInput();
}
return limited;
}),
],
decoration: const InputDecoration(
labelText: 'Desktop code, six digits',
border: InputBorder.none,
),
onChanged: onChanged,
onSubmitted: (_) => onSubmitted(),
),
),
),
ExcludeSemantics(
child: Row(
children: [
for (var index = 0; index < 6; index++) ...[
if (index > 0)
SizedBox(width: index == 3 ? Grid.twelve : Grid.half),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled ? () => focusCell(index) : null,
child: AnimatedContainer(
key: Key('pairing-code-cell-$index'),
duration: reducedMotion
? Duration.zero
: Duration(milliseconds: invalid ? 280 : 150),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
decoration: BoxDecoration(
color: context._onboardingInputSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: invalid
? context._onboardingErrorInk
: focus.hasFocus && activeIndex == index
? context._onboardingInk
: context.colors.primary.withValues(alpha: 0.15),
),
),
child: ClipRect(
child: AnimatedSwitcher(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 150),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) =>
FadeTransition(
opacity: animation,
child: AnimatedBuilder(
animation: animation,
builder: (context, child) =>
Transform.translate(
offset: Offset(
0,
reducedMotion
? 0
: 8 * (1 - animation.value),
),
child: child,
),
child: child,
),
),
child: Text(
index < editing.text.length
? editing.text[index]
: ' ',
key: ValueKey(
index < editing.text.length
? editing.text[index]
: '',
),
textAlign: TextAlign.center,
style: context.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w600,
color: context._onboardingInk,
),
),
),
),
),
),
),
],
],
),
),
],
);
}
}