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
119 changes: 100 additions & 19 deletions src/components/PinPad.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
import { useReducedMotion } from 'motion/react';
import { useMorph } from 'shape-morph/react';
import { useEffect, useState, type KeyboardEvent, type RefObject } from 'react';
import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from 'react';
import { PIN_LENGTH } from '../privacy/appProtectionStore';
import {
chooseExpressivePinShape,
type ExpressivePinShapeName,
} from '../privacy/expressivePinShapes';
import { Icon } from './Icon';

const PIN_SHAPE_HOLD_MS = 80;
const PIN_SHAPE_MORPH_MS = 220;
const PIN_SHAPE_HOLD_MS = 260;
const PIN_SHAPE_MORPH_MS = 140;
const PIN_DOT_EXIT_MS = 160;
const PIN_DOT_STEP_PX = 24;

function MorphingPinDot({ shape }: { shape: ExpressivePinShapeName }) {
type PinIndicatorItem = {
id: number;
phase: 'active' | 'exiting';
shape: ExpressivePinShapeName;
};

function MorphingPinDot({
item,
onExitComplete,
position,
}: {
item: PinIndicatorItem;
onExitComplete: (id: number) => void;
position: number;
}) {
const reducedMotion = Boolean(useReducedMotion());
const [morphToCircle, setMorphToCircle] = useState(reducedMotion);
const { pathD, progress } = useMorph(shape, 'Circle', {
const { pathD, progress } = useMorph(item.shape, 'Circle', {
duration: PIN_SHAPE_MORPH_MS,
progress: morphToCircle ? 1 : 0,
size: 100,
Expand All @@ -29,41 +45,106 @@ function MorphingPinDot({ shape }: { shape: ExpressivePinShapeName }) {
return () => window.clearTimeout(timer);
}, [reducedMotion]);

useEffect(() => {
if (item.phase !== 'exiting') return;
if (reducedMotion) {
onExitComplete(item.id);
return;
}
const fallback = window.setTimeout(() => onExitComplete(item.id), PIN_DOT_EXIT_MS + 40);
return () => window.clearTimeout(fallback);
}, [item.id, item.phase, onExitComplete, reducedMotion]);

return (
<span
className={`pin-indicator${reducedMotion ? '' : ' pin-indicator--entering'}`}
className="pin-indicator"
data-indicator-state={item.phase}
data-morph-progress={progress.toFixed(3)}
data-start-shape={shape}
data-start-shape={item.shape}
style={{ transform: `translateX(${position}px)` }}
>
<svg focusable="false" viewBox="0 0 100 100">
<path d={pathD} />
</svg>
<span
className="pin-indicator__shape"
onTransitionEnd={(event) => {
if (item.phase === 'exiting' && event.propertyName === 'transform') onExitComplete(item.id);
}}
style={{
'--pin-shape-hold': `${PIN_SHAPE_HOLD_MS}ms`,
'--pin-shape-morph': `${PIN_SHAPE_MORPH_MS}ms`,
} as CSSProperties}
>
<span className="pin-indicator__settle">
<span className="pin-indicator__grow">
<svg focusable="false" viewBox="0 0 100 100">
<path d={pathD} />
</svg>
</span>
</span>
</span>
</span>
);
}

function PinIndicators({ length, resetToken }: { length: number; resetToken?: number }) {
const [shapes, setShapes] = useState<ExpressivePinShapeName[]>(() =>
Array.from({ length }, chooseExpressivePinShape));
const nextIndicatorId = useRef(length);
const previousResetToken = useRef(resetToken);
const [indicators, setIndicators] = useState<PinIndicatorItem[]>(() =>
Array.from({ length }, (_, index) => ({
id: index,
phase: 'active',
shape: chooseExpressivePinShape(),
})));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const activeLength = indicators.filter(({ phase }) => phase === 'active').length;
const removeIndicator = useCallback((id: number) => {
setIndicators((current) => current.filter((indicator) => indicator.id !== id));
}, []);

useEffect(() => {
setShapes((current) => {
if (length < current.length) return current.slice(0, length);
if (length === current.length) return current;
if (length === activeLength) return;
if (length < activeLength) {
setIndicators((current) => {
let keptActive = 0;
return current.map((indicator) => {
if (indicator.phase === 'exiting') return indicator;
if (keptActive++ < length) return indicator;
return { ...indicator, phase: 'exiting' };
});
});
return;
}

setIndicators((current) => {
const missing = length - current.filter(({ phase }) => phase === 'active').length;
if (missing <= 0) return current;
return [
...current,
...Array.from({ length: length - current.length }, chooseExpressivePinShape),
...Array.from({ length: missing }, () => ({
id: nextIndicatorId.current++,
phase: 'active' as const,
shape: chooseExpressivePinShape(),
})),
];
});
}, [length]);
}, [activeLength, length]);

useEffect(() => {
setShapes([]);
if (previousResetToken.current === resetToken) return;
previousResetToken.current = resetToken;
setIndicators((current) => current.map((indicator) => (
indicator.phase === 'exiting' ? indicator : { ...indicator, phase: 'exiting' }
)));
}, [resetToken]);

return (
<div aria-hidden="true" className="pin-indicators" data-filled={length}>
{shapes.map((shape, index) => <MorphingPinDot key={`${index}-${shape}`} shape={shape} />)}
{indicators.map((item, index) => (
<MorphingPinDot
item={item}
key={item.id}
onExitComplete={removeIndicator}
position={(index - (indicators.length - 1) / 2) * PIN_DOT_STEP_PX}
/>
))}
</div>
);
}
Expand Down
95 changes: 72 additions & 23 deletions src/styles/lockscreen.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,39 +62,74 @@ html[data-app-covered='true'] body > dialog {
}

.pin-indicators {
display: flex;
position: relative;
width: 100%;
min-height: 32px;
align-items: center;
justify-content: center;
gap: 8px;
}

.pin-indicator {
position: absolute;
top: 50%;
left: 50%;
display: grid;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
flex: 0 0 16px;
color: var(--color-on-surface);
place-items: center;
transform-origin: center;
transition: transform var(--motion-short) var(--motion-emphasized);
}

.pin-indicator svg {
display: block;
.pin-indicator__shape {
display: grid;
width: 16px;
height: 16px;
overflow: visible;
fill: currentColor;
place-items: center;
transform: scale(1);
transform-origin: center;
}

.pin-indicator__settle,
.pin-indicator__grow {
display: grid;
width: 16px;
height: 16px;
place-items: center;
transform-origin: center;
}

.pin-indicator__settle {
animation: pin-dot-settle var(--pin-shape-morph) var(--motion-emphasized) var(--pin-shape-hold) both;
}

.pin-indicator__grow {
animation: pin-dot-grow 160ms var(--motion-emphasized) both;
}

.pin-indicator[data-indicator-state='exiting'] .pin-indicator__shape {
opacity: 0;
transform: scale(0);
transition: opacity 160ms linear, transform 160ms cubic-bezier(0.4, 0, 1, 1);
}

@keyframes pin-dot-grow {
from { opacity: 0; transform: scale(0); }
to { opacity: 1; transform: scale(1.5); }
}

.pin-indicator--entering {
animation: pin-dot-enter 380ms var(--motion-emphasized) both;
@keyframes pin-dot-settle {
from { transform: scale(1); }
to { transform: scale(0.5); }
}

@keyframes pin-dot-enter {
0% { opacity: 0; transform: scale(0); }
28% { opacity: 1; transform: scale(1.75); }
100% { opacity: 1; transform: scale(1); }
.pin-indicator svg {
display: block;
width: 16px;
height: 16px;
overflow: visible;
fill: currentColor;
}

.pin-pad__message {
Expand All @@ -119,7 +154,7 @@ html[data-app-covered='true'] body > dialog {
}

.pin-keypad {
--pin-key-size: clamp(58px, 18vw, 74px);
--pin-key-size: clamp(66px, 20.63vw, 85px);
display: grid;
grid-template-columns: repeat(3, var(--pin-key-size));
gap: clamp(9px, 2.8vw, 15px) clamp(14px, 4.2vw, 22px);
Expand All @@ -135,13 +170,13 @@ html[data-app-covered='true'] body > dialog {
min-height: var(--pin-key-size);
padding: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--color-outline-variant) 48%, transparent);
border: none;
border-radius: 50%;
background: color-mix(in srgb, var(--color-surface-bright) 72%, var(--color-primary-container));
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-surface-bright) 70%, transparent);
box-shadow: none;
color: var(--color-on-surface);
cursor: pointer;
font-size: clamp(22px, 7vw, 29px);
font-size: clamp(25px, 7.5vw, 32px);
font-variation-settings: 'ROND' 100, 'wdth' 100;
font-weight: 560;
place-items: center;
Expand Down Expand Up @@ -388,7 +423,8 @@ html[data-app-covered='true'] body > dialog {

.pin-management-dialog {
display: flex;
width: min(100%, 520px);
width: 339px;
max-width: 100%;
max-height: calc(100dvh - max(32px, env(safe-area-inset-top) + env(safe-area-inset-bottom)));
padding: 0 var(--space-5) var(--space-5);
overflow-y: auto;
Expand Down Expand Up @@ -442,7 +478,8 @@ html[data-app-covered='true'] body > dialog {
}

.pin-management-dialog__supporting {
max-width: 390px;
width: 100%;
min-height: 57px;
margin: var(--space-4) auto 0;
color: var(--color-on-surface-variant);
font-size: 13px;
Expand All @@ -451,7 +488,7 @@ html[data-app-covered='true'] body > dialog {
}

.pin-management-dialog .pin-pad {
margin-top: var(--space-5);
margin-top: var(--space-8);
}

@media (max-height: 700px) {
Expand Down Expand Up @@ -492,22 +529,34 @@ html[data-app-covered='true'] body > dialog {
}

.pin-management-dialog {
width: 100%;
max-height: 100dvh;
width: 100vw;
max-width: none;
height: 100dvh;
min-height: 100dvh;
max-height: none;
padding-bottom: max(var(--space-5), env(safe-area-inset-bottom));
border-radius: 0;
align-self: stretch;
justify-self: stretch;
}
}

@media (prefers-reduced-motion: reduce) {
.pin-indicator,
.pin-indicator__shape,
.pin-key,
.pin-key::after,
.settings-switch-row__track,
.settings-switch-row__track i {
transition: none;
}

.pin-indicator__settle,
.pin-indicator__grow {
animation: none;
transform: none;
}

.pin-key:active {
transform: none;
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading