- frontend: visitor public home (AiAssistant, AiPlanningBriefing, VisitorTrackPage), 3-track public routing (PublicShell/App), i18n ko/en/zh/ja, favicon - backend: public AI visitor-assistant + event calendar API (publicsite/*), profile avatar API (auth/profile/*), SecurityConfig CORS whitelist, SecretStartupValidator (B12 fail-fast, prod only), application-prod.yml, AppIntegrity, /api/auth/me expansion (MeResponse) - db: V42 bulk demo seed, V43 visitor_guide/transport + event calendar view, V44 performance indexes, V45 app_user profile photo columns (all idempotent) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
689 lines
25 KiB
TypeScript
689 lines
25 KiB
TypeScript
/*
|
|
* SCR-M(내정보/프로필) — WISE 모바일 mypage 이식(구조·컴포넌트·플로우 문자 그대로 차용).
|
|
* 레퍼런스: guardia-messenger/app/uiws/mypage.tsx.
|
|
* · 프로필 헤드(아바타+카메라 배지 → 사진 선택→미리보기→적용 시에만 업로드)
|
|
* · 정보 행(InfoRow) · 계정/보안(2FA 상태) · 생체인식 잠금 토글 · 환경설정 · 로그아웃
|
|
* 추가: ① 생체인식 로컬 잠금(진입 게이트) ② 프로필 사진 등록(갤러리/카메라).
|
|
* 보안: 새 시크릿·토큰·자격증명을 console로 출력하지 않는다. 생체 원본은 OS에만.
|
|
*/
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { router, useFocusEffect } from 'expo-router';
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Image,
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Switch,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { Avatar } from '../components/Avatar';
|
|
import { Banner } from '../components/Banner';
|
|
import { Card } from '../components/Card';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useSecureScreen } from '../context/SecureScreenContext';
|
|
import { ApiRequestError, isDegraded } from '../lib/api';
|
|
import {
|
|
authenticateBiometric,
|
|
isBiometricEnabled,
|
|
isBiometricSupported,
|
|
isSessionUnlocked,
|
|
markSessionUnlocked,
|
|
setBiometricEnabled,
|
|
} from '../lib/biometric';
|
|
import {
|
|
isPickerAvailable,
|
|
pickFromCamera,
|
|
pickFromLibrary,
|
|
type PickedImage,
|
|
} from '../lib/imagePick';
|
|
import { avatarUrl, getMe, getOtpStatus, uploadAvatar } from '../lib/profile';
|
|
import { prefGet, prefSet } from '../lib/secureStore';
|
|
import type { EventRole, MePrincipal, OtpStatusDto } from '../lib/types';
|
|
import { colors, radius, spacing, touch, type } from '../theme';
|
|
|
|
const roleLabel: Record<EventRole, string> = {
|
|
ORGANIZER: '주최자',
|
|
EXHIBITOR: '참가업체',
|
|
CONTRACTOR: '장치·시공업체',
|
|
HALL_MANAGER: '홀매니저',
|
|
};
|
|
|
|
// 환경설정(이 기기 로컬 저장 — 서버 동기화 계약 부재, web MyPage와 동일 정책).
|
|
const PREFS_KEY = 'kintex.prefs';
|
|
interface Prefs {
|
|
notifyDeadline: boolean;
|
|
notifyApproval: boolean;
|
|
notifyPayment: boolean;
|
|
}
|
|
const DEFAULT_PREFS: Prefs = { notifyDeadline: true, notifyApproval: true, notifyPayment: true };
|
|
|
|
export default function ProfileScreen() {
|
|
const { user, signOut } = useAuth();
|
|
|
|
// 개인정보 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7).
|
|
useSecureScreen('profile');
|
|
|
|
const [me, setMe] = useState<MePrincipal | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadErr, setLoadErr] = useState('');
|
|
const [otp, setOtp] = useState<OtpStatusDto | null>(null);
|
|
|
|
// 프로필 사진 — 선택 → 미리보기(적용/취소) → 적용 시에만 업로드(WISE 불변식).
|
|
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
|
|
const [photoBusy, setPhotoBusy] = useState(false);
|
|
const [previewImg, setPreviewImg] = useState<PickedImage | null>(null);
|
|
const [previewErr, setPreviewErr] = useState('');
|
|
const [photoPending, setPhotoPending] = useState(false); // 서버 미반영(degrade) 로컬 프리뷰 상태
|
|
|
|
// 생체인식
|
|
const [bioSupported, setBioSupported] = useState(false);
|
|
const [bioEnabled, setBioEnabled] = useState(false);
|
|
const [locked, setLocked] = useState(false); // 잠금 게이트 통과 전 콘텐츠 차단
|
|
|
|
// 환경설정
|
|
const [prefs, setPrefs] = useState<Prefs>(DEFAULT_PREFS);
|
|
|
|
const displayName = me?.displayName ?? user?.displayName ?? '사용자';
|
|
|
|
const loadProfile = useCallback(async () => {
|
|
setLoading(true);
|
|
setLoadErr('');
|
|
try {
|
|
const p = await getMe();
|
|
setMe(p);
|
|
setPhotoUrl(avatarUrl(p.userId));
|
|
setPhotoPending(false);
|
|
} catch (e) {
|
|
// /me 실패 시 최소한 로그인 세션 정보로 화면 유지(전면 오류 회피).
|
|
setLoadErr(e instanceof ApiRequestError ? e.message : '내정보를 불러오지 못했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
try {
|
|
setOtp(await getOtpStatus());
|
|
} catch {
|
|
setOtp(null); // 상태 조회 실패는 무시(섹션은 안내로 degrade)
|
|
}
|
|
}, []);
|
|
|
|
// 환경설정 로드
|
|
useEffect(() => {
|
|
prefGet(PREFS_KEY)
|
|
.then((raw) => {
|
|
if (raw) setPrefs({ ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial<Prefs>) });
|
|
})
|
|
.catch(() => setPrefs(DEFAULT_PREFS));
|
|
}, []);
|
|
|
|
// 생체 지원/토글 상태
|
|
useEffect(() => {
|
|
isBiometricSupported().then(setBioSupported).catch(() => setBioSupported(false));
|
|
isBiometricEnabled().then(setBioEnabled).catch(() => setBioEnabled(false));
|
|
}, []);
|
|
|
|
// 진입 게이트 — 생체 잠금 활성 + 세션 미해제 시 인증 요구.
|
|
const runLockGate = useCallback(async () => {
|
|
const enabled = await isBiometricEnabled();
|
|
if (!enabled || isSessionUnlocked()) {
|
|
setLocked(false);
|
|
return;
|
|
}
|
|
setLocked(true);
|
|
const ok = await authenticateBiometric();
|
|
if (ok) {
|
|
markSessionUnlocked();
|
|
setLocked(false);
|
|
}
|
|
}, []);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
runLockGate();
|
|
loadProfile();
|
|
}, [runLockGate, loadProfile]),
|
|
);
|
|
|
|
const savePrefs = async (next: Prefs) => {
|
|
setPrefs(next);
|
|
try {
|
|
await prefSet(PREFS_KEY, JSON.stringify(next));
|
|
} catch {
|
|
/* graceful */
|
|
}
|
|
};
|
|
|
|
const toggleBiometric = async (next: boolean) => {
|
|
if (next) {
|
|
// 활성화 전 1회 본인 확인.
|
|
const ok = await authenticateBiometric();
|
|
if (!ok) {
|
|
Alert.alert('생체인식', '생체 인증에 실패했습니다. 다시 시도해 주세요.');
|
|
return;
|
|
}
|
|
markSessionUnlocked(); // 방금 인증했으므로 이 세션은 통과 처리(즉시 재프롬프트 방지).
|
|
}
|
|
await setBiometricEnabled(next);
|
|
setBioEnabled(next);
|
|
};
|
|
|
|
// ── 사진 선택(미리보기용) — 업로드 호출 없음 ──
|
|
const pickForPreview = async (from: 'library' | 'camera') => {
|
|
if (photoBusy) return;
|
|
if (!isPickerAvailable()) {
|
|
Alert.alert('프로필 사진', '이미지 선택 기능을 사용할 수 없는 기기입니다.');
|
|
return;
|
|
}
|
|
const img = from === 'library' ? await pickFromLibrary() : await pickFromCamera();
|
|
if (!img) {
|
|
// 권한 거부/취소/미설치: 모달이 열려있으면 유지, 아니면 무동작 + 권한 안내.
|
|
return;
|
|
}
|
|
setPreviewImg(img);
|
|
setPreviewErr('');
|
|
};
|
|
|
|
// ── 적용 — 이 시점에만 1회 업로드. 미구현/네트워크면 로컬 프리뷰로 degrade ──
|
|
const applyPhoto = async () => {
|
|
if (!previewImg || photoBusy) return;
|
|
setPhotoBusy(true);
|
|
setPreviewErr('');
|
|
try {
|
|
const res = await uploadAvatar(previewImg);
|
|
const base = res.photoUrl || (me ? avatarUrl(me.userId) : null);
|
|
setPhotoUrl(base ? `${base}${base.includes('?') ? '&' : '?'}_t=${Date.now()}` : previewImg.uri);
|
|
setPhotoPending(false);
|
|
setPreviewImg(null);
|
|
} catch (e) {
|
|
if (isDegraded(e)) {
|
|
// 백엔드 아바타 업로드 미구현 — 로컬 프리뷰 유지 + 대기 안내(파괴적 실패 아님).
|
|
setPhotoUrl(previewImg.uri);
|
|
setPhotoPending(true);
|
|
setPreviewImg(null);
|
|
} else {
|
|
setPreviewErr(e instanceof ApiRequestError ? e.message : '사진 업로드에 실패했습니다.');
|
|
}
|
|
} finally {
|
|
setPhotoBusy(false);
|
|
}
|
|
};
|
|
|
|
const cancelPreview = () => {
|
|
if (photoBusy) return;
|
|
setPreviewImg(null);
|
|
setPreviewErr('');
|
|
};
|
|
|
|
const onChangePhoto = () => {
|
|
if (photoBusy) return;
|
|
Alert.alert('프로필 사진', '사진을 선택하세요', [
|
|
{ text: '갤러리', onPress: () => pickForPreview('library') },
|
|
{ text: '카메라', onPress: () => pickForPreview('camera') },
|
|
{ text: '취소', style: 'cancel' },
|
|
]);
|
|
};
|
|
|
|
const onSignOut = () => {
|
|
Alert.alert('로그아웃', '로그아웃하시겠습니까?', [
|
|
{ text: '취소', style: 'cancel' },
|
|
{
|
|
text: '로그아웃',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
await signOut();
|
|
router.replace('/login');
|
|
},
|
|
},
|
|
]);
|
|
};
|
|
|
|
// 생체 잠금 게이트 — 미통과 시 콘텐츠 차단 화면.
|
|
if (locked) {
|
|
return (
|
|
<View style={styles.lockWrap}>
|
|
<Ionicons name="finger-print" size={56} color={colors.primary600} />
|
|
<Text style={styles.lockTitle}>생체인식 잠금</Text>
|
|
<Text style={styles.lockHint}>지문 또는 Face로 잠금을 해제하세요.</Text>
|
|
<Pressable
|
|
style={styles.lockBtn}
|
|
onPress={runLockGate}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="생체 인증 다시 시도"
|
|
>
|
|
<Text style={styles.lockBtnText}>다시 시도</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const roles = me?.eventRoles ? Object.values(me.eventRoles) : [];
|
|
|
|
return (
|
|
<ScrollView style={styles.wrap} contentContainerStyle={styles.scroll}>
|
|
{/* ── 프로필 ── */}
|
|
<Text style={styles.section}>내 정보</Text>
|
|
{loading ? (
|
|
<ActivityIndicator color={colors.primary600} style={{ marginVertical: spacing.md }} />
|
|
) : (
|
|
<Card>
|
|
<View style={styles.profileHead}>
|
|
<Pressable
|
|
testID="profile-photo"
|
|
onPress={onChangePhoto}
|
|
disabled={photoBusy}
|
|
accessibilityLabel="프로필 사진 변경"
|
|
accessibilityRole="button"
|
|
>
|
|
<Avatar photoUrl={photoUrl} name={displayName} size={56} testID="profile-avatar" />
|
|
<View style={styles.photoEdit}>
|
|
{photoBusy ? (
|
|
<ActivityIndicator color={colors.white} size="small" />
|
|
) : (
|
|
<Ionicons name="camera" size={13} color={colors.white} />
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
<View style={{ flex: 1, marginLeft: 12 }}>
|
|
<Text style={styles.profileName}>{displayName}</Text>
|
|
<Text style={styles.profileId}>
|
|
{me?.userId ?? user?.userId ?? '-'}
|
|
{me?.roleCode ? ` · ${me.roleCode}` : ''}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{photoPending ? (
|
|
<Text style={styles.pendingNote}>
|
|
선택한 사진은 이 기기에만 표시됩니다 — 서버 반영은 준비 중입니다.
|
|
</Text>
|
|
) : null}
|
|
|
|
{loadErr ? <Banner tone="warning">{loadErr}</Banner> : null}
|
|
|
|
<InfoRow label="이름" value={displayName} />
|
|
<InfoRow label="사용자 ID" value={me?.userId ?? user?.userId ?? null} testID="profile-userid" />
|
|
<InfoRow label="전역 역할" value={me?.roleCode ?? null} />
|
|
<InfoRow label="전시관(테넌트)" value={me?.tenantId ?? null} />
|
|
<InfoRow
|
|
label="구분"
|
|
value={me?.hallManager ?? user?.hallManager ? '킨텍스 내부(홀매니저)' : '일반 계정'}
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ── 참여 행사 역할 ── */}
|
|
{roles.length > 0 ? (
|
|
<>
|
|
<Text style={styles.section}>참여 행사 역할</Text>
|
|
<Card>
|
|
{Object.entries(me!.eventRoles).map(([eventId, r]) => (
|
|
<View key={eventId} style={styles.infoRow}>
|
|
<Text style={styles.infoLabel} numberOfLines={1}>
|
|
{eventId}
|
|
</Text>
|
|
<View style={styles.rolePill}>
|
|
<Text style={styles.rolePillText}>{roleLabel[r] ?? r}</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</Card>
|
|
</>
|
|
) : null}
|
|
|
|
{/* ── 계정·보안(2차 인증) ── */}
|
|
<Text style={styles.section}>계정 · 보안</Text>
|
|
<Card>
|
|
<View style={styles.bioRow}>
|
|
<View style={{ flex: 1, paddingRight: 12 }}>
|
|
<Text style={styles.rowTitle}>2차 인증(OTP)</Text>
|
|
<Text style={styles.hint}>
|
|
{otp?.otpEnabled
|
|
? `활성화됨 · ${otp.verifyMethod === 'OTP' ? 'Authenticator(OTP)' : otp.verifyMethod || 'OTP'}`
|
|
: '미설정 — 로그인 시 인증 앱(TOTP) 2단계 인증을 사용합니다.'}
|
|
</Text>
|
|
</View>
|
|
<View style={otp?.otpEnabled ? styles.badgeOn : styles.badgeOff}>
|
|
<Text style={otp?.otpEnabled ? styles.badgeOnText : styles.badgeOffText}>
|
|
{otp?.otpEnabled ? '켜짐' : '꺼짐'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
|
|
{/* ── 생체인식 잠금 ── */}
|
|
<Text style={styles.section}>생체인식 잠금</Text>
|
|
<Card>
|
|
{bioSupported ? (
|
|
<View style={styles.bioRow}>
|
|
<View style={{ flex: 1, paddingRight: 12 }}>
|
|
<Text style={styles.rowTitle}>지문 · Face 잠금</Text>
|
|
<Text style={styles.hint}>
|
|
켜면 내정보 진입 시 지문 또는 Face로 재인증합니다. 로그인 2차 인증은 그대로 유지됩니다.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
testID="profile-biometric-toggle"
|
|
value={bioEnabled}
|
|
onValueChange={toggleBiometric}
|
|
trackColor={{ false: colors.neutral200, true: colors.primary600 }}
|
|
thumbColor={colors.white}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<Text style={styles.hint}>
|
|
이 기기는 생체인식을 지원하지 않거나 지문/Face가 등록되어 있지 않습니다. 기기 설정에서 생체
|
|
정보를 등록한 뒤 다시 시도해 주세요.
|
|
</Text>
|
|
)}
|
|
</Card>
|
|
|
|
{/* ── 환경설정(알림) ── */}
|
|
<Text style={styles.section}>알림 설정</Text>
|
|
<Card>
|
|
{(
|
|
[
|
|
['notifyDeadline', '마감 D-데이 알림'],
|
|
['notifyApproval', '승인·검수 알림'],
|
|
['notifyPayment', '결제·정산 알림'],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<View key={key} style={styles.bioRow}>
|
|
<Text style={[styles.rowTitle, { flex: 1 }]}>{label}</Text>
|
|
<Switch
|
|
testID={`profile-pref-${key}`}
|
|
value={prefs[key]}
|
|
onValueChange={(v) => savePrefs({ ...prefs, [key]: v })}
|
|
trackColor={{ false: colors.neutral200, true: colors.primary600 }}
|
|
thumbColor={colors.white}
|
|
/>
|
|
</View>
|
|
))}
|
|
<Text style={styles.hint}>알림 설정은 이 기기에 저장됩니다. 서버 동기화는 준비 중입니다.</Text>
|
|
</Card>
|
|
|
|
{/* ── 로그아웃 ── */}
|
|
<Pressable
|
|
style={styles.logoutBtn}
|
|
onPress={onSignOut}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="로그아웃"
|
|
>
|
|
<Ionicons name="log-out-outline" size={18} color={colors.error} />
|
|
<Text style={styles.logoutText}>로그아웃</Text>
|
|
</Pressable>
|
|
|
|
<Text style={styles.version}>KINTEX AI 전시관리 · 내정보</Text>
|
|
|
|
{/* ── 프로필 사진 미리보기 모달(선택→미리보기→적용 확정) ── */}
|
|
<Modal visible={!!previewImg} transparent animationType="fade" onRequestClose={cancelPreview}>
|
|
<Pressable style={styles.modalBackdrop} onPress={cancelPreview}>
|
|
<Pressable style={styles.modalSheet} onPress={() => {}}>
|
|
<View style={styles.modalHeaderRow}>
|
|
<Text style={styles.modalTitle}>프로필 사진 변경</Text>
|
|
<Pressable
|
|
testID="photo-cancel-x"
|
|
onPress={cancelPreview}
|
|
disabled={photoBusy}
|
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
accessibilityLabel="닫기"
|
|
>
|
|
<Ionicons name="close" size={22} color={colors.neutral500} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View style={styles.previewWrap}>
|
|
{previewImg ? (
|
|
<Image
|
|
testID="photo-preview-image"
|
|
source={{ uri: previewImg.uri }}
|
|
style={styles.previewImg}
|
|
resizeMode="cover"
|
|
/>
|
|
) : null}
|
|
{photoBusy ? (
|
|
<View style={styles.previewOverlay}>
|
|
<ActivityIndicator color={colors.white} size="small" />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<Text style={styles.previewCaption}>원형으로 표시됩니다.</Text>
|
|
|
|
<View style={styles.modalPickRow}>
|
|
<Pressable
|
|
testID="photo-pick-library"
|
|
style={[styles.pickBtn, photoBusy && styles.disabled]}
|
|
onPress={() => pickForPreview('library')}
|
|
disabled={photoBusy}
|
|
>
|
|
<Ionicons name="image-outline" size={16} color={colors.neutral900} />
|
|
<Text style={styles.pickText}>갤러리</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
testID="photo-pick-camera"
|
|
style={[styles.pickBtn, photoBusy && styles.disabled]}
|
|
onPress={() => pickForPreview('camera')}
|
|
disabled={photoBusy}
|
|
>
|
|
<Ionicons name="camera-outline" size={16} color={colors.neutral900} />
|
|
<Text style={styles.pickText}>카메라</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{previewErr ? <Text style={styles.err}>{previewErr}</Text> : null}
|
|
|
|
<Pressable
|
|
testID="photo-apply"
|
|
style={[styles.applyBtn, photoBusy && styles.disabled]}
|
|
onPress={applyPhoto}
|
|
disabled={photoBusy}
|
|
>
|
|
{photoBusy ? (
|
|
<View style={styles.applyBusyRow}>
|
|
<ActivityIndicator color={colors.white} size="small" />
|
|
<Text style={styles.applyText}>적용 중…</Text>
|
|
</View>
|
|
) : (
|
|
<Text style={styles.applyText}>적용</Text>
|
|
)}
|
|
</Pressable>
|
|
<Pressable
|
|
testID="photo-cancel"
|
|
style={[styles.cancelBtn, photoBusy && styles.disabled]}
|
|
onPress={cancelPreview}
|
|
disabled={photoBusy}
|
|
>
|
|
<Text style={styles.cancelText}>취소</Text>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value, testID }: { label: string; value: string | null; testID?: string }) {
|
|
return (
|
|
<View style={styles.infoRow}>
|
|
<Text style={styles.infoLabel}>{label}</Text>
|
|
<Text style={styles.infoValue} testID={testID} numberOfLines={1}>
|
|
{value && value.length ? value : '-'}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
wrap: { flex: 1, backgroundColor: colors.neutral050 },
|
|
scroll: { padding: spacing.md, paddingBottom: spacing.xl },
|
|
section: {
|
|
color: colors.neutral900,
|
|
fontSize: type.h3.fontSize,
|
|
fontWeight: '800',
|
|
marginTop: spacing.md,
|
|
marginBottom: spacing.sm,
|
|
},
|
|
|
|
profileHead: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm },
|
|
photoEdit: {
|
|
position: 'absolute',
|
|
right: -2,
|
|
bottom: -2,
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.primary600,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 2,
|
|
borderColor: colors.white,
|
|
},
|
|
profileName: { color: colors.neutral900, fontSize: type.h2.fontSize, fontWeight: '800' },
|
|
profileId: { color: colors.neutral500, fontSize: type.caption.fontSize, marginTop: 3 },
|
|
pendingNote: { color: colors.warning, fontSize: type.caption.fontSize, marginBottom: spacing.sm },
|
|
|
|
infoRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingVertical: 9,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.neutral200,
|
|
},
|
|
infoLabel: { color: colors.neutral500, fontSize: type.caption.fontSize, fontWeight: '700' },
|
|
infoValue: {
|
|
color: colors.neutral900,
|
|
fontSize: type.body.fontSize,
|
|
fontWeight: '600',
|
|
flexShrink: 1,
|
|
textAlign: 'right',
|
|
marginLeft: 12,
|
|
},
|
|
rolePill: {
|
|
backgroundColor: colors.primary100,
|
|
borderRadius: radius.pill,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
},
|
|
rolePillText: { color: colors.primary700, fontSize: type.caption.fontSize, fontWeight: '700' },
|
|
|
|
bioRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4 },
|
|
rowTitle: { color: colors.neutral900, fontSize: type.body.fontSize, fontWeight: '700', marginBottom: 4 },
|
|
hint: { color: colors.neutral500, fontSize: type.caption.fontSize, lineHeight: 18, marginTop: 4 },
|
|
|
|
badgeOn: { backgroundColor: '#E7F5EF', borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 },
|
|
badgeOnText: { color: colors.success, fontSize: type.caption.fontSize, fontWeight: '800' },
|
|
badgeOff: { backgroundColor: colors.neutral200, borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 },
|
|
badgeOffText: { color: colors.neutral700, fontSize: type.caption.fontSize, fontWeight: '800' },
|
|
|
|
logoutBtn: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
marginTop: spacing.lg,
|
|
minHeight: touch.min,
|
|
borderRadius: radius.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.error,
|
|
backgroundColor: colors.white,
|
|
},
|
|
logoutText: { color: colors.error, fontSize: type.h3.fontSize, fontWeight: '700' },
|
|
version: { textAlign: 'center', color: colors.neutral500, fontSize: type.caption.fontSize, marginTop: spacing.md },
|
|
|
|
// 생체 잠금 게이트
|
|
lockWrap: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: spacing.sm,
|
|
padding: spacing.lg,
|
|
backgroundColor: colors.neutral050,
|
|
},
|
|
lockTitle: { color: colors.neutral900, fontSize: type.h2.fontSize, fontWeight: '800', marginTop: spacing.sm },
|
|
lockHint: { color: colors.neutral500, fontSize: type.body.fontSize, textAlign: 'center' },
|
|
lockBtn: {
|
|
marginTop: spacing.md,
|
|
minHeight: touch.min,
|
|
paddingHorizontal: spacing.lg,
|
|
borderRadius: radius.sm,
|
|
backgroundColor: colors.primary600,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
lockBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' },
|
|
|
|
// 사진 미리보기 모달
|
|
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' },
|
|
modalSheet: {
|
|
backgroundColor: colors.white,
|
|
borderTopLeftRadius: radius.md,
|
|
borderTopRightRadius: radius.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
padding: spacing.lg,
|
|
paddingBottom: spacing.xl,
|
|
},
|
|
modalHeaderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
modalTitle: { color: colors.neutral900, fontSize: type.h3.fontSize, fontWeight: '800' },
|
|
previewWrap: {
|
|
width: 120,
|
|
height: 120,
|
|
borderRadius: 60,
|
|
alignSelf: 'center',
|
|
marginVertical: spacing.md,
|
|
overflow: 'hidden',
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
backgroundColor: colors.primary050,
|
|
},
|
|
previewImg: { width: 120, height: 120, borderRadius: 60 },
|
|
previewOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
|
},
|
|
previewCaption: { color: colors.neutral500, fontSize: type.caption.fontSize, textAlign: 'center', marginTop: -4 },
|
|
modalPickRow: { flexDirection: 'row', gap: 10, marginTop: spacing.md },
|
|
pickBtn: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
minHeight: touch.min,
|
|
borderRadius: radius.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
backgroundColor: colors.white,
|
|
},
|
|
pickText: { color: colors.neutral900, fontSize: type.body.fontSize, fontWeight: '700' },
|
|
applyBtn: {
|
|
marginTop: spacing.md,
|
|
minHeight: touch.min,
|
|
borderRadius: radius.sm,
|
|
backgroundColor: colors.primary600,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
applyBusyRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
applyText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '800' },
|
|
cancelBtn: {
|
|
marginTop: spacing.sm,
|
|
minHeight: touch.min,
|
|
borderRadius: radius.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.white,
|
|
},
|
|
cancelText: { color: colors.neutral700, fontSize: type.h3.fontSize, fontWeight: '700' },
|
|
err: { color: colors.error, fontSize: type.caption.fontSize, marginTop: spacing.sm },
|
|
disabled: { opacity: 0.6 },
|
|
});
|