Skip to content
Open
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
80 changes: 56 additions & 24 deletions apps/mobile/app/(auth)/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Stack } from 'expo-router';
import { X } from 'lucide-react-native';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Animated, Easing, Keyboard, Linking, Platform, Pressable, StyleSheet, View } from 'react-native';
import { Animated, Easing, Keyboard, Linking, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { AccountDeletionStatus, SocialProvider, VerificationKind } from '@cindy/auth-client';

Expand Down Expand Up @@ -42,6 +42,7 @@ import { computeLoginKeyboardShift } from '@/auth/loginKeyboardAvoidance';
import { useLoginHandoffOptional } from '@/auth/MobileLoginHandoffContext';
import {
createResendDeadline,
LOGIN_ACCOUNT_LIST,
LOGIN_CONSENT_ROW,
LOGIN_CONTROL,
LOGIN_DELETION_BUBBLE,
Expand All @@ -55,6 +56,7 @@ import {
LOGIN_TITLE,
resolveDeletionBubbleFrame,
resolveDeletionBubbleLinkHitSlop,
resolveLoginAccountListLayout,
type LoginDeletionBubbleFrame,
type LoginSurfaceMode,
} from '@/auth/loginSkinLayout';
Expand Down Expand Up @@ -278,9 +280,19 @@ export function LoginScreen({
const [accountDeletionStatus, setAccountDeletionStatus] =
useState<AccountDeletionStatus | null>(null);
const [accountSwitcherVisible, setAccountSwitcherVisible] = useState(false);
const accountSelectionScrollRef = useRef<ScrollView>(null);
const styles = useThemedStyles(makeStyles);
const configIssues = getMobileConfigIssues();
const disabled = auth.isBusy || !auth.initialized || configIssues.length > 0;
const accountSelectionCount =
auth.loginState?.step === 'account-selection'
? auth.loginState.accounts.length
: 0;

useEffect(() => {
if (accountSelectionCount <= LOGIN_ACCOUNT_LIST.visibleRowCount) return;
accountSelectionScrollRef.current?.flashScrollIndicators();
}, [accountSelectionCount]);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -918,36 +930,56 @@ export function LoginScreen({
const renderAccountSelection = () => {
const state = auth.loginState;
if (state?.step !== 'account-selection') return null;
const listLayout = resolveLoginAccountListLayout(state.accounts.length);
return (
<LoginPanel testID="login.panel.accountSelection">
{backNode}
<LoginTitleBlock
title={loginText('chooseAccount')}
subtitle={loginText('chooseAccountSubtitle')}
/>
{state.accounts.map((account, index) => (
<LoginMethodRow
accessibilityLabel={account.displayName}
disabled={disabled}
icon={account.kind === 'org' ? 'enterprise' : 'person'}
key={account.id}
onPress={() =>
void auth.dispatchLoginAction({
type: 'select-account',
accountId: account.id,
})
}
subtitle={
account.orgName || account.email || loginText('personalAccount')
}
testID={`login.account.${account.id}`}
title={account.displayName}
top={
LOGIN_METHOD_ROW.firstRowTopSsoOrg +
index * LOGIN_METHOD_ROW.rowStep
}
/>
))}
<ScrollView
bounces={false}
contentContainerStyle={{ height: listLayout.contentHeight }}
contentInsetAdjustmentBehavior="never"
overScrollMode="never"
ref={accountSelectionScrollRef}
scrollEnabled={
state.accounts.length > LOGIN_ACCOUNT_LIST.visibleRowCount
}
showsVerticalScrollIndicator={
state.accounts.length > LOGIN_ACCOUNT_LIST.visibleRowCount
}
style={{
height: listLayout.viewportHeight,
left: 0,
position: 'absolute',
top: LOGIN_ACCOUNT_LIST.viewportTop,
width: loginSizes.panelWidth,
}}
testID="login.accountList"
>
{state.accounts.map((account, index) => (
<LoginMethodRow
accessibilityLabel={account.displayName}
disabled={disabled}
icon={account.kind === 'org' ? 'enterprise' : 'person'}
key={account.id}
onPress={() =>
void auth.dispatchLoginAction({
type: 'select-account',
accountId: account.id,
})
}
subtitle={
account.orgName || account.email || loginText('personalAccount')
}
testID={`login.account.${account.id}`}
title={account.displayName}
top={index * LOGIN_METHOD_ROW.rowStep}
/>
))}
</ScrollView>
{errorNode}
</LoginPanel>
);
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/auth/__tests__/loginSkinLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ vi.mock("expo-localization", () => ({
import {
createResendDeadline,
formatResendCountdown,
LOGIN_ACCOUNT_LIST,
LOGIN_CONTROL,
LOGIN_DELETION_BUBBLE,
LOGIN_ERROR_TEXT,
LOGIN_METHOD_ROW,
LOGIN_PAD_LANDSCAPE_STAGE,
LOGIN_PAD_PORTRAIT_STAGE,
LOGIN_STAGE_LONG,
Expand All @@ -24,6 +27,7 @@ import {
resendCountdownRemaining,
resolveDeletionBubbleFrame,
resolveDeletionBubbleLinkHitSlop,
resolveLoginAccountListLayout,
resolveLoginStage,
resolveLoginSurface,
resolveLoginSurfaceMode,
Expand All @@ -40,6 +44,24 @@ function expectBox(actual: LoginStageBox, expected: LoginStageBox) {
}

describe("loginSkin 750 stage 布局引擎", () => {
it("五个身份使用独立滚动视口:前两项位置不变,末项可完整滚入", () => {
const layout = resolveLoginAccountListLayout(5);

expect(LOGIN_ACCOUNT_LIST.viewportTop).toBe(
LOGIN_METHOD_ROW.firstRowTopSsoOrg,
);
expect(layout.rowTops.slice(0, 2)).toEqual([0, LOGIN_METHOD_ROW.rowStep]);
expect(layout.viewportHeight).toBe(
LOGIN_ERROR_TEXT.y - LOGIN_METHOD_ROW.firstRowTopSsoOrg,
);
expect(layout.contentHeight).toBe(
LOGIN_METHOD_ROW.height + 4 * LOGIN_METHOD_ROW.rowStep,
);
expect(layout.lastRowTopAtMaxScroll + LOGIN_METHOD_ROW.height).toBe(
layout.viewportHeight,
);
});

it("scale 与 designHeight clamp:vw/750 缩放,dh clamp [600,1800]", () => {
const layout = resolveLoginStage(390, 844);
expect(layout.scale).toBeCloseTo(390 / 750, 10);
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/auth/__tests__/loginSkinStates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ describe('loginSkin 全登录态(harness 真链 + 渲染层接线)', () => {
expect(loginSource).toContain(
"account.orgName || account.email || loginText('personalAccount')",
);
expect(loginSource).toContain('ref={accountSelectionScrollRef}');
expect(loginSource).toContain('showsVerticalScrollIndicator={');
expect(loginSource).toContain('resolveLoginAccountListLayout(');
expect(loginSource).toContain('top={index * LOGIN_METHOD_ROW.rowStep}');
});

it('binding-contact:outcome:binding-phone 真链→绑定阶段一(输入+发送)', async () => {
Expand Down
47 changes: 47 additions & 0 deletions apps/mobile/src/auth/loginSkinLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,53 @@ export const LOGIN_METHOD_ROW = {
firstRowTopSsoOrg: 148,
rowStep: 120,
} as const;

/**
* 身份选择列表的独立滚动视口。
*
* 标题 / 返回按钮留在面板固定层;视口从原首行 y=148 开始,到错误提示槽 y=380
* 结束,因此默认完整显示两行且不会与错误文案重叠。更多身份在该区域内滚动。
*/
export const LOGIN_ACCOUNT_LIST = {
viewportTop: LOGIN_METHOD_ROW.firstRowTopSsoOrg,
viewportHeight:
LOGIN_ERROR_TEXT.y - LOGIN_METHOD_ROW.firstRowTopSsoOrg,
visibleRowCount: 2,
} as const;

export interface LoginAccountListLayout {
viewportHeight: number;
contentHeight: number;
maxScrollOffset: number;
lastRowTopAtMaxScroll: number;
rowTops: number[];
}

/** 纯几何:给定身份数,计算滚动内容高度与最大滚动位置。 */
export function resolveLoginAccountListLayout(
accountCount: number,
): LoginAccountListLayout {
const count = Math.max(0, Math.floor(accountCount));
const rowTops = Array.from(
{ length: count },
(_, index) => index * LOGIN_METHOD_ROW.rowStep,
);
const rowsHeight =
count === 0
? 0
: LOGIN_METHOD_ROW.height + (count - 1) * LOGIN_METHOD_ROW.rowStep;
const contentHeight = Math.max(LOGIN_ACCOUNT_LIST.viewportHeight, rowsHeight);
const maxScrollOffset = contentHeight - LOGIN_ACCOUNT_LIST.viewportHeight;
const lastRowTop = rowTops.at(-1) ?? 0;

return {
viewportHeight: LOGIN_ACCOUNT_LIST.viewportHeight,
contentHeight,
maxScrollOffset,
lastRowTopAtMaxScroll: lastRowTop - maxScrollOffset,
rowTops,
};
}
/** 大 loading 环(figma §5.2:64×64 @(308,158 browser / 193 preparing))。 */
export const LOGIN_LOADING_RING = { x: 308, yBrowser: 158, yPreparing: 193, size: 64 } as const;
/** Text_link / 倒计时(figma §4.7:@(70,238) 540×50 20;行高走 LOGIN_COPY_LINE_HEIGHT)。 */
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