diff --git a/src/features/inviteFriends/components/ThemedQrCard.tsx b/src/features/inviteFriends/components/ThemedQrCard.tsx index 394d6ecca5..586eed3b74 100644 --- a/src/features/inviteFriends/components/ThemedQrCard.tsx +++ b/src/features/inviteFriends/components/ThemedQrCard.tsx @@ -1,16 +1,23 @@ import {lazy} from 'react' import {View} from 'react-native' // @ts-expect-error missing types -import QRCode from 'react-native-qrcode-styled' +import QRCode, {useQRCodeData} from 'react-native-qrcode-styled' +import {Path} from 'react-native-svg' import type ViewShot from 'react-native-view-shot' import {Image} from 'expo-image' import {LinearGradient} from 'expo-linear-gradient' import {Logo} from '#/view/icons/Logo' import {atoms as a, useTheme} from '#/alf' -import {hexToRgb, rgbToHex} from '#/alf/util/colorGeneration' import {Text} from '#/components/Typography' import {type InviteThemeVariant} from '../themes' +import { + BUTTERFLY_PIECE_SCALE, + butterflyPiecePath, + eyePath, + finderCornerAnchor, + isInFinderRegion, +} from './qrButterflies' const LazyViewShot = lazy( // @ts-expect-error dynamic import @@ -19,9 +26,28 @@ const LazyViewShot = lazy( const CARD_WIDTH = 278 const CARD_GRADIENT_PADDING = 12 +// White panel the QR sits on. const QR_AREA = CARD_WIDTH - CARD_GRADIENT_PADDING * 2 -const QR_PIECE_SIZE = 7 -const QR_INNER_PADDING = 4 +// White quiet-zone ring between the QR and the gradient card border, on each +// side. A bit tighter than CARD_GRADIENT_PADDING so the QR reads as the focal +// element while still keeping a clear scan margin. +const QR_MARGIN = 10 +// The QR's on-screen size. Fixed regardless of module count so longer handles +// (denser matrices) don't overflow the panel - we scale pieceSize to fit, the +// way the design prototype does, rather than letting the QR grow with the data. +const QR_RENDER_SIZE = QR_AREA - QR_MARGIN * 2 +// Small internal quiet zone, in QR units. Also keeps the outermost butterflies +// (which overhang their module) from clipping at the SVG edge. +const QR_QUIET = 6 +// High error correction keeps the butterfly modules (which cover less of each +// cell than solid squares) and the centered avatar scannable. +const ERROR_CORRECTION_LEVEL = 'H' +const QR_DATA_OPTIONS = {errorCorrectionLevel: ERROR_CORRECTION_LEVEL} as const +// react-native-qrcode-styled ships no resolvable types; type just what we use. +const useResolvedQrCodeData = useQRCodeData as ( + data: string, + options: typeof QR_DATA_OPTIONS, +) => {qrCodeSize: number} const AVATAR_WRAPPER = 72 const AVATAR_IMAGE = 60 const AVATAR_BORDER = 2 @@ -40,6 +66,12 @@ export function ThemedQrCard({ captureRef: React.Ref }) { const t = useTheme() + // Derive pieceSize from the module count so the QR always renders at + // QR_RENDER_SIZE px (svgSize + 2*QR_QUIET == QR_RENDER_SIZE), independent of + // handle length. + const {qrCodeSize} = useResolvedQrCodeData(shareUrl, QR_DATA_OPTIONS) + const pieceSize = + qrCodeSize > 0 ? (QR_RENDER_SIZE - 2 * QR_QUIET) / qrCodeSize : 6 return ( + ) + } + if (isInFinderRegion(x, y, n)) return null + if (isInLogoArea(x, y, pieceSize, qrSize)) return null + if (bitMatrix[y]?.[x] !== 1) return null + + return ( + + ) +} + +/** + * Clears a circular region in the QR center for the avatar overlay. The QR + * renders at QR_RENDER_SIZE px with a viewBox of qrSize + 2*QR_QUIET == that + * same size, so one display px equals one QR unit and the AVATAR_WRAPPER px + * overlay maps to a radius of AVATAR_WRAPPER / 2 QR units. + */ +function isInLogoArea( + x: number, + y: number, + pieceSize: number, + qrSize: number, +): boolean { + const clearRadius = AVATAR_WRAPPER / 2 + pieceSize * 0.5 + const cx = (x + 0.5) * pieceSize + const cy = (y + 0.5) * pieceSize + const center = qrSize / 2 + return Math.hypot(cx - center, cy - center) <= clearRadius +} + +/** + * Returns the `gradient` prop shape that react-native-qrcode-styled accepts. + * Mirrors the card's top-to-bottom LinearGradient so the QR data appears as a + * continuous extension of the card gradient rather than a separate overlay. */ function qrGradient(from: string, to: string) { return { @@ -217,30 +258,3 @@ function qrGradient(from: string, to: string) { }, } } - -// Vertical positions (0..1 along the QR) where the corner eye centers sit. -// QR is 33 modules square; eye centers are at module index 3 and 29. -const EYE_TOP_T = 3 / 33 -const EYE_BOTTOM_T = 29 / 33 - -/** - * Returns the solid color the canvas-wide gradient would paint at vertical - * position `t` (0=top, 1=bottom). We use this to color each eye so that the - * eye blends seamlessly into the gradient on the surrounding data pieces. - * Eyes can't use a local `gradient` prop here - react-native-qrcode-styled - * 0.3.3 has an internal ID mismatch where the eye gradient are - * registered as `${pos}CornerSquareGradient` / `${pos}CornerDotGradient` - * but the Path fill references `url(#${pos}OuterEyeGradient)` / - * `url(#${pos}InnerEyeGradient)`, so per-eye gradients silently render - * with no fill. - */ -function eyeColor(from: string, to: string, t: number): string { - const fromRgb = hexToRgb(from) - const toRgb = hexToRgb(to) - if (!fromRgb || !toRgb) return from - return rgbToHex( - fromRgb.r + (toRgb.r - fromRgb.r) * t, - fromRgb.g + (toRgb.g - fromRgb.g) * t, - fromRgb.b + (toRgb.b - fromRgb.b) * t, - ) -} diff --git a/src/features/inviteFriends/components/qrButterflies.ts b/src/features/inviteFriends/components/qrButterflies.ts new file mode 100644 index 0000000000..cfca019095 --- /dev/null +++ b/src/features/inviteFriends/components/qrButterflies.ts @@ -0,0 +1,224 @@ +/** + * Geometry helpers for rendering the Invite Friends QR code with butterfly data + * modules (APP-2417). + * + * react-native-qrcode-styled draws every "on" module as a rounded square. To + * draw butterflies instead we take over per-module rendering via its + * `renderCustomPieceItem` hook, which bypasses the library's own eye + logo + * handling, so we reproduce all of it here as plain SVG path strings. + * + * All paths are emitted in absolute QR-canvas coordinates (no per-element + * transform) so the single canvas-wide `userSpaceOnUse` gradient maps + * continuously across butterflies and eyes alike. Applying a transform per + * element would instead remap the gradient into each element's local box. + */ + +/** Finder ("eye") patterns are 7x7 modules in each non-bottom-right corner. */ +export const FINDER_SIZE = 7 + +/** + * Scale of each butterfly relative to a module. >1 lets neighbours overlap into + * a denser, more legible silhouette. 1.2 matches the approved prototype and, + * with error-correction level H, scans as reliably as solid squares. + */ +export const BUTTERFLY_PIECE_SCALE = 1.2 + +// Eye corner radii, in module units. Outer ~= 2.3 modules keeps the current +// design's 16px outer-corner look (16 / 7px piece). +const EYE_RADIUS_OUTER = 2.3 +const EYE_RADIUS_CUT = 1.4 +const EYE_RADIUS_DOT = 1.1 + +export type FinderCorner = 'topLeft' | 'topRight' | 'bottomLeft' + +/** + * The bsky butterfly outline (from the bsky-qr prototype), as a single closed + * path over a 600x530 viewBox. + */ +const BSKY_PATH_DATA = + 'm135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z' +const BSKY_VIEW_W = 600 +const BSKY_VIEW_H = 530 + +type BskyCmd = + | {type: 'M'; x: number; y: number} + | { + type: 'C' + x1: number + y1: number + x2: number + y2: number + x: number + y: number + } + | {type: 'Z'} + +/** + * Parse the butterfly path once into normalized commands centered on (0,0) and + * scaled to a unit box (uniform scale on the larger viewBox dimension). The path + * only uses M/m, C/c and Z, so the parser handles just those. + */ +function parseBskyPath(): BskyCmd[] { + const cmds: BskyCmd[] = [] + const tokens = BSKY_PATH_DATA.match(/[a-zA-Z]|-?(?:\d+\.?\d*|\.\d+)/g) ?? [] + const scale = 1 / Math.max(BSKY_VIEW_W, BSKY_VIEW_H) + const offsetX = -(BSKY_VIEW_W * scale) / 2 + const offsetY = -(BSKY_VIEW_H * scale) / 2 + const norm = (x: number, y: number) => ({ + x: x * scale + offsetX, + y: y * scale + offsetY, + }) + + let i = 0 + let cx = 0 + let cy = 0 + let lastCmd = '' + while (i < tokens.length) { + const t = tokens[i] + if (/[a-zA-Z]/.test(t)) { + lastCmd = t + i++ + if (lastCmd === 'Z' || lastCmd === 'z') cmds.push({type: 'Z'}) + continue + } + if (lastCmd === 'M' || lastCmd === 'm') { + const x = parseFloat(tokens[i++]) + const y = parseFloat(tokens[i++]) + if (lastCmd === 'm') { + cx += x + cy += y + } else { + cx = x + cy = y + } + cmds.push({type: 'M', ...norm(cx, cy)}) + lastCmd = lastCmd === 'm' ? 'l' : 'L' + } else if (lastCmd === 'C' || lastCmd === 'c') { + const x1 = parseFloat(tokens[i++]) + const y1 = parseFloat(tokens[i++]) + const x2 = parseFloat(tokens[i++]) + const y2 = parseFloat(tokens[i++]) + const x = parseFloat(tokens[i++]) + const y = parseFloat(tokens[i++]) + const rel = lastCmd === 'c' + const ax1 = rel ? cx + x1 : x1 + const ay1 = rel ? cy + y1 : y1 + const ax2 = rel ? cx + x2 : x2 + const ay2 = rel ? cy + y2 : y2 + const ax = rel ? cx + x : x + const ay = rel ? cy + y : y + const p1 = norm(ax1, ay1) + const p2 = norm(ax2, ay2) + const p = norm(ax, ay) + cmds.push({ + type: 'C', + x1: p1.x, + y1: p1.y, + x2: p2.x, + y2: p2.y, + x: p.x, + y: p.y, + }) + cx = ax + cy = ay + } else { + i++ + } + } + return cmds +} + +const BSKY_NORMALIZED = parseBskyPath() + +/** A butterfly centered on (centerX, centerY), sized to `scale` canvas units. */ +export function butterflyPiecePath( + centerX: number, + centerY: number, + scale: number, +): string { + const tx = (px: number) => centerX + px * scale + const ty = (py: number) => centerY + py * scale + let p = '' + for (const c of BSKY_NORMALIZED) { + if (c.type === 'M') { + p += `M${tx(c.x)} ${ty(c.y)}` + } else if (c.type === 'C') { + p += `C${tx(c.x1)} ${ty(c.y1)} ${tx(c.x2)} ${ty(c.y2)} ${tx(c.x)} ${ty(c.y)}` + } else { + p += 'Z' + } + } + return p +} + +function roundedRectPath( + x: number, + y: number, + w: number, + h: number, + r: number, +): string { + const rr = Math.min(r, w / 2, h / 2) + return ( + `M${x + rr} ${y} H${x + w - rr} A${rr} ${rr} 0 0 1 ${x + w} ${y + rr} ` + + `V${y + h - rr} A${rr} ${rr} 0 0 1 ${x + w - rr} ${y + h} ` + + `H${x + rr} A${rr} ${rr} 0 0 1 ${x} ${y + h - rr} ` + + `V${y + rr} A${rr} ${rr} 0 0 1 ${x + rr} ${y} Z` + ) +} + +/** + * Returns the finder corner whose top-left module is (x, y), or null. Used to + * emit each eye exactly once, at its anchor cell. + */ +export function finderCornerAnchor( + x: number, + y: number, + n: number, +): FinderCorner | null { + if (x === 0 && y === 0) return 'topLeft' + if (x === n - FINDER_SIZE && y === 0) return 'topRight' + if (x === 0 && y === n - FINDER_SIZE) return 'bottomLeft' + return null +} + +/** Whether (x, y) falls inside any of the three finder boxes. */ +export function isInFinderRegion(x: number, y: number, n: number): boolean { + return ( + (x < FINDER_SIZE && y < FINDER_SIZE) || + (x >= n - FINDER_SIZE && y < FINDER_SIZE) || + (x < FINDER_SIZE && y >= n - FINDER_SIZE) + ) +} + +/** + * A single even-odd path for one eye: outer rounded ring + inner cut + center + * dot. Even-odd winding fills the ring band and the dot while leaving the gap + * between them empty, reproducing a finder pattern. + */ +export function eyePath( + corner: FinderCorner, + n: number, + pieceSize: number, +): string { + const left = corner === 'topRight' ? (n - FINDER_SIZE) * pieceSize : 0 + const top = corner === 'bottomLeft' ? (n - FINDER_SIZE) * pieceSize : 0 + const size = FINDER_SIZE * pieceSize + return [ + roundedRectPath(left, top, size, size, EYE_RADIUS_OUTER * pieceSize), + roundedRectPath( + left + pieceSize, + top + pieceSize, + size - 2 * pieceSize, + size - 2 * pieceSize, + EYE_RADIUS_CUT * pieceSize, + ), + roundedRectPath( + left + 2 * pieceSize, + top + 2 * pieceSize, + 3 * pieceSize, + 3 * pieceSize, + EYE_RADIUS_DOT * pieceSize, + ), + ].join(' ') +}