684 lines
24 KiB
TypeScript
684 lines
24 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 { useTranslation } from 'react-i18next';
|
|
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 { LanguageSelector } from '../components/LanguageSelector';
|
|
import { avatarUrl, getMe, getOtpStatus, uploadAvatar } from '../lib/profile';
|
|
import { prefGet, prefSet } from '../lib/secureStore';
|
|
import type { MePrincipal, OtpStatusDto } from '../lib/types';
|
|
import { colors, radius, spacing, touch, type } from '../theme';
|
|
|
|
// 환경설정(이 기기 로컬 저장 — 서버 동기화 계약 부재, 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 { t } = useTranslation();
|
|
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 ?? t('common.user');
|
|
|
|
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 : t('profile.loadErr'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
try {
|
|
setOtp(await getOtpStatus());
|
|
} catch {
|
|
setOtp(null); // 상태 조회 실패는 무시(섹션은 안내로 degrade)
|
|
}
|
|
}, [t]);
|
|
|
|
// 환경설정 로드
|
|
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(t('profile.biometric'), t('profile.bioFailed'));
|
|
return;
|
|
}
|
|
markSessionUnlocked(); // 방금 인증했으므로 이 세션은 통과 처리(즉시 재프롬프트 방지).
|
|
}
|
|
await setBiometricEnabled(next);
|
|
setBioEnabled(next);
|
|
};
|
|
|
|
// ── 사진 선택(미리보기용) — 업로드 호출 없음 ──
|
|
const pickForPreview = async (from: 'library' | 'camera') => {
|
|
if (photoBusy) return;
|
|
if (!isPickerAvailable()) {
|
|
Alert.alert(t('profile.photoTitle'), t('profile.photoPickerUnavailable'));
|
|
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 : t('profile.uploadErr'));
|
|
}
|
|
} finally {
|
|
setPhotoBusy(false);
|
|
}
|
|
};
|
|
|
|
const cancelPreview = () => {
|
|
if (photoBusy) return;
|
|
setPreviewImg(null);
|
|
setPreviewErr('');
|
|
};
|
|
|
|
const onChangePhoto = () => {
|
|
if (photoBusy) return;
|
|
Alert.alert(t('profile.photoTitle'), t('profile.photoSelect'), [
|
|
{ text: t('profile.photoLibrary'), onPress: () => pickForPreview('library') },
|
|
{ text: t('profile.photoCamera'), onPress: () => pickForPreview('camera') },
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
]);
|
|
};
|
|
|
|
const onSignOut = () => {
|
|
Alert.alert(t('profile.signOut'), t('profile.signOutConfirm'), [
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{
|
|
text: t('profile.signOut'),
|
|
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}>{t('profile.lockTitle')}</Text>
|
|
<Text style={styles.lockHint}>{t('profile.lockHint')}</Text>
|
|
<Pressable
|
|
style={styles.lockBtn}
|
|
onPress={runLockGate}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('common.retry')}
|
|
>
|
|
<Text style={styles.lockBtnText}>{t('common.retry')}</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const roles = me?.eventRoles ? Object.values(me.eventRoles) : [];
|
|
|
|
return (
|
|
<ScrollView style={styles.wrap} contentContainerStyle={styles.scroll}>
|
|
{/* ── 프로필 ── */}
|
|
<Text style={styles.section}>{t('profile.sectionInfo')}</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}>{t('profile.photoPending')}</Text>
|
|
) : null}
|
|
|
|
{loadErr ? <Banner tone="warning">{loadErr}</Banner> : null}
|
|
|
|
<InfoRow label={t('profile.labelName')} value={displayName} />
|
|
<InfoRow label={t('profile.labelUserId')} value={me?.userId ?? user?.userId ?? null} testID="profile-userid" />
|
|
<InfoRow label={t('profile.labelGlobalRole')} value={me?.roleCode ?? null} />
|
|
<InfoRow label={t('profile.labelTenant')} value={me?.tenantId ?? null} />
|
|
<InfoRow
|
|
label={t('profile.labelType')}
|
|
value={me?.hallManager ?? user?.hallManager ? t('profile.typeInternal') : t('profile.typeGeneral')}
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ── 참여 행사 역할 ── */}
|
|
{roles.length > 0 ? (
|
|
<>
|
|
<Text style={styles.section}>{t('profile.sectionEventRoles')}</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}>{t(`roles.${r}` as const, { defaultValue: r })}</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</Card>
|
|
</>
|
|
) : null}
|
|
|
|
{/* ── 계정·보안(2차 인증) ── */}
|
|
<Text style={styles.section}>{t('profile.sectionSecurity')}</Text>
|
|
<Card>
|
|
<View style={styles.bioRow}>
|
|
<View style={{ flex: 1, paddingRight: 12 }}>
|
|
<Text style={styles.rowTitle}>{t('profile.otpTitle')}</Text>
|
|
<Text style={styles.hint}>
|
|
{otp?.otpEnabled
|
|
? otp.verifyMethod === 'OTP'
|
|
? t('profile.otpOnAuth')
|
|
: t('profile.otpOnMethod', { method: otp.verifyMethod || 'OTP' })
|
|
: t('profile.otpOff')}
|
|
</Text>
|
|
</View>
|
|
<View style={otp?.otpEnabled ? styles.badgeOn : styles.badgeOff}>
|
|
<Text style={otp?.otpEnabled ? styles.badgeOnText : styles.badgeOffText}>
|
|
{otp?.otpEnabled ? t('profile.on') : t('profile.off')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
|
|
{/* ── 생체인식 잠금 ── */}
|
|
<Text style={styles.section}>{t('profile.sectionBiometric')}</Text>
|
|
<Card>
|
|
{bioSupported ? (
|
|
<View style={styles.bioRow}>
|
|
<View style={{ flex: 1, paddingRight: 12 }}>
|
|
<Text style={styles.rowTitle}>{t('profile.bioTitle')}</Text>
|
|
<Text style={styles.hint}>{t('profile.bioDesc')}</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}>{t('profile.bioUnsupported')}</Text>
|
|
)}
|
|
</Card>
|
|
|
|
{/* ── 언어 설정 ── */}
|
|
<Text style={styles.section}>{t('lang.title')}</Text>
|
|
<LanguageSelector />
|
|
|
|
{/* ── 환경설정(알림) ── */}
|
|
<Text style={styles.section}>{t('profile.sectionNotify')}</Text>
|
|
<Card>
|
|
{(
|
|
[
|
|
['notifyDeadline', t('profile.notifyDeadline')],
|
|
['notifyApproval', t('profile.notifyApproval')],
|
|
['notifyPayment', t('profile.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 as keyof Prefs]}
|
|
onValueChange={(v) => savePrefs({ ...prefs, [key]: v })}
|
|
trackColor={{ false: colors.neutral200, true: colors.primary600 }}
|
|
thumbColor={colors.white}
|
|
/>
|
|
</View>
|
|
))}
|
|
<Text style={styles.hint}>{t('profile.notifyNote')}</Text>
|
|
</Card>
|
|
|
|
{/* ── 로그아웃 ── */}
|
|
<Pressable
|
|
style={styles.logoutBtn}
|
|
onPress={onSignOut}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('profile.signOut')}
|
|
>
|
|
<Ionicons name="log-out-outline" size={18} color={colors.error} />
|
|
<Text style={styles.logoutText}>{t('profile.signOut')}</Text>
|
|
</Pressable>
|
|
|
|
<Text style={styles.version}>{t('profile.version')}</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}>{t('profile.photoChange')}</Text>
|
|
<Pressable
|
|
testID="photo-cancel-x"
|
|
onPress={cancelPreview}
|
|
disabled={photoBusy}
|
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
accessibilityLabel={t('common.close')}
|
|
>
|
|
<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}>{t('profile.photoCaption')}</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}>{t('profile.photoLibrary')}</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}>{t('profile.photoCamera')}</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}>{t('profile.photoApplying')}</Text>
|
|
</View>
|
|
) : (
|
|
<Text style={styles.applyText}>{t('profile.photoApply')}</Text>
|
|
)}
|
|
</Pressable>
|
|
<Pressable
|
|
testID="photo-cancel"
|
|
style={[styles.cancelBtn, photoBusy && styles.disabled]}
|
|
onPress={cancelPreview}
|
|
disabled={photoBusy}
|
|
>
|
|
<Text style={styles.cancelText}>{t('common.cancel')}</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 },
|
|
});
|