mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## 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>
167 lines
6.7 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|