diff --git a/app.json b/app.json
index 9da53e7..e81c5ce 100644
--- a/app.json
+++ b/app.json
@@ -9,7 +9,8 @@
"plugins": ["expo-router"],
"web": {
"bundler": "metro",
- "output": "single"
+ "output": "single",
+ "favicon": "./assets/images/favicon.png"
},
"ios": {
"supportsTablet": true
diff --git a/assets/images/favicon.png b/assets/images/favicon.png
new file mode 100644
index 0000000..96fcf37
Binary files /dev/null and b/assets/images/favicon.png differ
diff --git a/assets/images/favicon.svg b/assets/images/favicon.svg
new file mode 100644
index 0000000..9cf5ab6
--- /dev/null
+++ b/assets/images/favicon.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/docs/profile-input.md b/docs/profile-input.md
new file mode 100644
index 0000000..511e727
--- /dev/null
+++ b/docs/profile-input.md
@@ -0,0 +1,20 @@
+# T-03 프로필 입력
+
+새 T-03 SVG 기준으로 프로필 화면을 분리했습니다.
+
+- 공통 TextInput, Chip, ActionButton, Loading 및 디자인 토큰 재사용
+- 이름: 앞뒤 공백 제거 기준 2~10자, 최대 입력 길이 10자. 빈 값·한 글자·공백만 입력 시 완료 비활성화
+- 과목: 선택 사항, 복수 선택 및 해제 가능. 390px 시안에서는 한 줄 배치, 작은 화면에서는 줄바꿈
+- 완료: 로컬 상태를 반영하고 기존 흐름대로 `/home` 이동
+- 설정의 수정: 기존 값 표시, 저장 후 설정으로 복귀
+- 저장 중: 공통 로딩 및 버튼 점 표시, 입력·과목 선택·중복 제출 차단
+- 저장 실패: 입력 유지 및 재시도 안내. 완료 버튼으로 재시도
+- API 미확정이므로 실제 서버 저장은 연결하지 않았습니다. `onSave`로 추후 확정된 API를 연결합니다.
+
+## 개발 미리보기
+
+- `/dev/profile`: 빈 입력 및 검증
+- `/dev/profile?scenario=loading`: 김나연·영어 선택. 완료 시 3초간 저장 중 표시 후 홈 이동
+- `/dev/profile?scenario=error`: 첫 저장 실패 후 완료 버튼 재시도 시 성공
+
+시나리오의 지연·오류는 개발 경로에만 있으며 production은 실제 프로필 경로로 전환됩니다.
diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png
new file mode 100644
index 0000000..bacf3b9
Binary files /dev/null and b/docs/screenshots/profile.png differ
diff --git a/docs/screenshots/terms.png b/docs/screenshots/terms.png
new file mode 100644
index 0000000..dd5aede
Binary files /dev/null and b/docs/screenshots/terms.png differ
diff --git a/docs/terms-consent.md b/docs/terms-consent.md
new file mode 100644
index 0000000..b7aa4fb
--- /dev/null
+++ b/docs/terms-consent.md
@@ -0,0 +1,24 @@
+# T-02 약관 동의 구현
+
+- `/terms`: 실제 가입 흐름의 약관 화면. API 미확정 상태로 서버 요청 없이 메모리의 동의 상태를 반영하고 `/profile`로 이동합니다.
+- 동의 전 다음 버튼은 비활성화됩니다. 개별 동의와 전체 동의를 양방향으로 동기화합니다.
+- 공통 `ActionButton`, `Loading`, 색상·간격·타이포그래피 토큰을 재사용합니다.
+- 약관 원문은 제공되지 않아 보기 버튼에서 미등록 안내를 표시합니다. 실제 법적 동의 및 저장 구현이 완료된 상태는 아닙니다.
+- `TermsScreen.onSave`는 확정된 API를 연결하기 위한 비동기 경계입니다. 입력은 화면 내부 모델이며 백엔드 요청 형식이 아닙니다. 약관 버전·동의 시각 등 요청 규격은 명세 확정 후 연결합니다.
+- 저장 실패 시 선택 상태를 유지하고 오류 안내 및 다시 시도 버튼을 표시합니다. 중복 요청을 막고 화면 종료 시 AbortSignal로 취소를 전달합니다.
+
+## 개발 중 미리보기
+
+개발 서버에서만 접근할 수 있으며 production에서는 `/terms`로 이동합니다.
+
+- `/dev/terms`: 정상 선택 및 저장 성공 (650ms 후 프로필 이동)
+- `/dev/terms?scenario=loading`: 전체 선택 후 시작. 다음 버튼을 누르면 3초간 공통 로딩 표시 후 성공
+- `/dev/terms?scenario=error`: 전체 선택 후 시작. 첫 제출은 실패, 다시 시도는 성공
+
+대기와 실패는 미리보기 전용이며 실제 가입 화면에는 인위적인 네트워크 지연을 추가하지 않았습니다.
+
+## 확인할 사항
+
+- 정상/저장 중/오류 SVG 기준 구현. OS 상태바는 실제 시스템 표시를 사용하고 정적 시각·배터리를 그리지 않습니다.
+- 미선택 상태 CTA는 필수 동의 차단을 위해 공통 비활성 스타일을 사용합니다.
+- 약관 원문과 동의 저장 API는 후속 연결이 필요합니다.
diff --git a/public/favicon.svg b/public/favicon.svg
index 6893eb1..9cf5ab6 100644
--- a/public/favicon.svg
+++ b/public/favicon.svg
@@ -1 +1,37 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/dev/profile.tsx b/src/app/dev/profile.tsx
new file mode 100644
index 0000000..43111d2
--- /dev/null
+++ b/src/app/dev/profile.tsx
@@ -0,0 +1,38 @@
+import { useRef } from 'react';
+import { Redirect, useLocalSearchParams } from 'expo-router';
+import ProfileScreen from '@/features/profile/ProfileScreen';
+import type { SaveProfile } from '@/features/profile/profile';
+export default function ProfilePreview() {
+ const { scenario } = useLocalSearchParams<{ scenario?: string }>();
+ const attempts = useRef(0);
+ if (!__DEV__) return ;
+ const save: SaveProfile = async (_, signal) => {
+ const attempt = ++attempts.current;
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => {
+ signal.removeEventListener('abort', abort);
+ resolve();
+ },
+ scenario === 'loading' ? 3000 : 650,
+ );
+ function abort() {
+ clearTimeout(timer);
+ reject(new Error('Aborted'));
+ }
+ signal.addEventListener('abort', abort, { once: true });
+ if (signal.aborted) abort();
+ });
+ if (scenario === 'error' && attempt === 1) throw new Error('Preview save failure');
+ };
+ return (
+
+ );
+}
diff --git a/src/app/dev/terms.tsx b/src/app/dev/terms.tsx
new file mode 100644
index 0000000..fa5f7d9
--- /dev/null
+++ b/src/app/dev/terms.tsx
@@ -0,0 +1,32 @@
+import { useRef } from 'react';
+import { Redirect, useLocalSearchParams } from 'expo-router';
+import TermsScreen from '@/features/terms/TermsScreen';
+import type { SaveTerms } from '@/features/terms/terms';
+
+export default function TermsPreview() {
+ const { scenario } = useLocalSearchParams<{ scenario?: string }>();
+ const attempts = useRef(0);
+ if (!__DEV__) return ;
+ const save: SaveTerms = async (_, signal) => {
+ const attempt = ++attempts.current;
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => {
+ signal.removeEventListener('abort', abort);
+ resolve();
+ },
+ scenario === 'loading' ? 3000 : 650,
+ );
+ function abort() {
+ clearTimeout(timer);
+ reject(new Error('Aborted'));
+ }
+ signal.addEventListener('abort', abort, { once: true });
+ if (signal.aborted) abort();
+ });
+ if (scenario === 'error' && attempt === 1) throw new Error('Preview save failure');
+ };
+ return (
+
+ );
+}
diff --git a/src/app/index.tsx b/src/app/index.tsx
index 3b495c4..4ed5e42 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -1,4 +1,5 @@
-import OnboardingApp from '@/features/onboarding/OnboardingApp';
-export default function Screen() {
- return ;
+import { Redirect } from 'expo-router';
+
+export default function Index() {
+ return ;
}
diff --git a/src/app/profile.tsx b/src/app/profile.tsx
index e672e91..3e5fc3f 100644
--- a/src/app/profile.tsx
+++ b/src/app/profile.tsx
@@ -1,8 +1,8 @@
-import OnboardingApp from '@/features/onboarding/OnboardingApp';
+import ProfileScreen from '@/features/profile/ProfileScreen';
import { Redirect } from 'expo-router';
import { useOnboarding } from '@/features/onboarding/OnboardingProvider';
export default function Screen() {
const { agreed, editing, profile } = useOnboarding();
if (!agreed.every(Boolean) && !(editing && profile.name)) return ;
- return ;
+ return ;
}
diff --git a/src/app/settings.tsx b/src/app/settings.tsx
index 357d372..3ef0a08 100644
--- a/src/app/settings.tsx
+++ b/src/app/settings.tsx
@@ -4,5 +4,5 @@ import { useOnboarding } from '@/features/onboarding/OnboardingProvider';
export default function Screen() {
const { profile } = useOnboarding();
if (!profile.name) return ;
- return ;
+ return ;
}
diff --git a/src/app/terms.tsx b/src/app/terms.tsx
index 73d1f32..a57b9bb 100644
--- a/src/app/terms.tsx
+++ b/src/app/terms.tsx
@@ -1,4 +1,2 @@
-import OnboardingApp from '@/features/onboarding/OnboardingApp';
-export default function Screen() {
- return ;
-}
+import TermsScreen from '@/features/terms/TermsScreen';
+export default TermsScreen;
diff --git a/src/features/onboarding/OnboardingApp.tsx b/src/features/onboarding/OnboardingApp.tsx
index 7e668c3..4f31ce8 100644
--- a/src/features/onboarding/OnboardingApp.tsx
+++ b/src/features/onboarding/OnboardingApp.tsx
@@ -1,10 +1,5 @@
-import emailImage from '../../../assets/images/email.svg';
-import googleImage from '../../../assets/images/google.svg';
-import kakaoImage from '../../../assets/images/kakao.svg';
-import logoImage from '../../../assets/images/logo.svg';
import { useState } from 'react';
import { useRouter } from 'expo-router';
-import { Image } from 'expo-image';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useOnboarding } from './OnboardingProvider';
import {
@@ -13,49 +8,27 @@ import {
ScrollView,
StyleSheet,
Text,
- TextInput,
View,
KeyboardAvoidingView,
Platform,
} from 'react-native';
-type Screen = 'welcome' | 'terms' | 'profile' | 'settings';
-const subjects = ['국어', '영어', '수학', '과학', '사회', '기타'];
-const terms = ['이용약관', '개인정보 수집 · 이용', '학부모 · 학생 연락처 처리'];
const ink = '#343845';
-export default function OnboardingApp({ screen }: { screen: Screen }) {
+export default function OnboardingApp() {
const router = useRouter();
- const {
- agreed,
- setAgreed,
- name,
- setName,
- selected,
- setSelected,
- profile,
- setProfile,
- editing,
- setEditing,
- } = useOnboarding();
- const setScreen = (next: Screen) => router.push(next === 'welcome' ? '/' : `/${next}`);
+ const { setAgreed, setName, setSelected, profile, setProfile, setEditing } = useOnboarding();
const [dialog, setDialog] = useState<{
title: string;
body: string;
action?: 'logout' | 'delete';
} | null>(null);
- const allAgreed = agreed.every(Boolean);
- const validName = name.trim().length >= 1 && name.trim().length <= 20;
const openTerms = (title: string) =>
setDialog({
title,
body: '약관 원문이 아직 제공되지 않았습니다. 현재 화면은 UI 확인용 데모이며 실제 가입이나 개인정보 수집은 이루어지지 않습니다. 정식 서비스에서는 확정된 약관을 연결해야 합니다.',
});
- const start = () => {
- setEditing(false);
- setScreen('terms');
- };
const reset = () => {
setName('');
setSelected([]);
@@ -67,7 +40,7 @@ export default function OnboardingApp({ screen }: { screen: Screen }) {
};
const back = () => {
if (router.canGoBack()) router.back();
- else router.replace(screen === 'profile' && !editing ? '/terms' : '/');
+ else router.replace('/');
};
const primary = (label: string, disabled: boolean, onPress: () => void) => (
- {screen === 'welcome' ? (
-
-
-
-
- 선생님은 수업만, 나머지는 수담이
-
-
-
-
- setDialog({
- title: '카카오 로그인',
- body: '카카오 로그인은 서버 연동 전입니다. 데모로 화면 흐름을 확인할 수 있어요.',
- })
- }
- style={({ pressed }) => [s.loginButton, s.kakao, pressed && s.pressed]}
- >
-
- 카카오로 시작하기
-
-
- setDialog({
- title: '구글 로그인',
- body: '구글 로그인은 서버 연동 전입니다. 데모로 화면 흐름을 확인할 수 있어요.',
- })
- }
- style={({ pressed }) => [s.loginButton, pressed && s.pressed]}
- >
-
- 구글로 시작하기
-
- [s.loginButton, pressed && s.pressed]}
- >
-
- 이메일로 가입하기
-
-
+ <>
+
+
+ ←
+
+
+ 설정
+
+
+
+ {
+ <>
+
+
+ {profile.name} 선생님
+
+ {profile.subjects.join(' · ') || '과목 미설정'}
+
+
+ {
+ setName(profile.name);
+ setSelected(profile.subjects);
+ setEditing(true);
+ router.push('/profile');
+ }}
+ >
+ 수정
+
+
+ {['이용약관', '개인정보 처리방침'].map((term) => (
+ openTerms(term)}
+ style={s.settingRow}
+ >
+ {term}
+ ›
+
+ ))}
+
setDialog({
- title: '이메일 로그인',
- body: '이메일 인증 및 로그인 API는 아직 연결되지 않았습니다.',
+ title: '로그아웃',
+ body: '데모 프로필을 지우고 시작 화면으로 돌아갈까요?',
+ action: 'logout',
})
}
>
- 이메일로 로그인
+ 로그아웃
- |
setDialog({
- title: '비밀번호 찾기',
- body: '비밀번호 재설정은 이메일 발송 API 연동 후 이용할 수 있습니다.',
+ title: '회원 탈퇴',
+ body: '실제 계정은 생성되지 않았습니다. 이 화면에서 입력한 데모 프로필을 삭제할까요?',
+ action: 'delete',
})
}
>
- 비밀번호 찾기
+ 회원 탈퇴
-
-
+ >
+ }
- ) : (
- <>
-
-
- ←
-
-
- {screen === 'terms' ? '약관 동의' : screen === 'profile' ? '프로필' : '설정'}
-
-
-
- {screen === 'terms' && (
- <>
- 수담을 쓰려면 동의가 필요해요
- setAgreed(agreed.map(() => !allAgreed))}
- style={[s.termRow, s.allRow]}
- >
-
- {allAgreed ? '✓' : ''}
-
- 전체 동의
-
- {terms.map((term, i) => (
-
- setAgreed(agreed.map((v, j) => (i === j ? !v : v)))}
- style={s.termChoice}
- >
-
- {agreed[i] ? '✓' : ''}
-
- [필수] {term}
-
- openTerms(term)}
- style={s.viewTerm}
- >
- 보기
-
-
- ))}
- >
- )}
- {screen === 'profile' && (
- <>
- 학부모님께 보일 이름을 알려 주세요
-
- 이름 (필수)
-
-
- 과목 (선택, 여러 개)
-
- {subjects.map((subject) => (
-
- setSelected(
- selected.includes(subject)
- ? selected.filter((v) => v !== subject)
- : [...selected, subject],
- )
- }
- style={[s.chip, selected.includes(subject) && s.selectedChip]}
- >
-
- {subject}
-
-
- ))}
-
- >
- )}
- {screen === 'settings' && (
- <>
-
-
- {profile.name} 선생님
-
- {profile.subjects.join(' · ') || '과목 미설정'}
-
-
- {
- setName(profile.name);
- setSelected(profile.subjects);
- setEditing(true);
- setScreen('profile');
- }}
- >
- 수정
-
-
- {['이용약관', '개인정보 처리방침'].map((term) => (
- openTerms(term)}
- style={s.settingRow}
- >
- {term}
- ›
-
- ))}
-
-
- setDialog({
- title: '로그아웃',
- body: '데모 프로필을 지우고 시작 화면으로 돌아갈까요?',
- action: 'logout',
- })
- }
- >
- 로그아웃
-
-
- setDialog({
- title: '회원 탈퇴',
- body: '실제 계정은 생성되지 않았습니다. 이 화면에서 입력한 데모 프로필을 삭제할까요?',
- action: 'delete',
- })
- }
- >
- 회원 탈퇴
-
- >
- )}
-
- {screen === 'terms' && (
-
- {primary('다음', !allAgreed, () => setScreen('profile'))}
-
- )}
- {screen === 'profile' && (
-
- {primary(editing ? '저장' : '완료', !validName, () => {
- setProfile({ name: name.trim(), subjects: selected });
- if (editing) {
- if (router.canGoBack()) router.back();
- else router.replace('/settings');
- return;
- }
- if (router.canDismiss()) router.dismissAll();
- router.replace('/home');
- })}
-
- )}
- >
- )}
+ >
{primary('확인', false, reset)}
) : (
- <>
- {screen === 'welcome' &&
- (dialog?.title === '카카오 로그인' || dialog?.title === '구글 로그인') && (
- {
- setDialog(null);
- start();
- }}
- style={s.demoButton}
- >
- 데모로 계속하기
-
- )}
- {primary('닫기', false, () => setDialog(null))}
- >
+ <>{primary('닫기', false, () => setDialog(null))}>
)}
@@ -403,25 +186,6 @@ const s = StyleSheet.create({
flex: 1,
paddingTop: Platform.OS === 'web' ? 44 : 0,
},
- welcome: { flexGrow: 1, paddingHorizontal: 28, justifyContent: 'space-between' },
- brand: { alignItems: 'center', paddingTop: 189 },
- logo: { width: 230, height: 51, marginBottom: 16 },
- tagline: { marginTop: 8, fontSize: 14, fontWeight: '600', color: '#151515' },
- blue: { color: '#1557ff' },
- loginActions: { paddingBottom: 64, gap: 10, marginTop: 100 },
- loginButton: {
- height: 58,
- borderRadius: 8,
- borderWidth: 1,
- borderColor: '#d2d5dc',
- alignItems: 'center',
- justifyContent: 'center',
- flexDirection: 'row',
- },
- kakao: { backgroundColor: '#ffe500', borderColor: '#ffe500' },
- loginText: { color: ink, fontSize: 16, fontWeight: '600', marginLeft: 56 },
- provider: { position: 'absolute', left: 78, width: 28, height: 28 },
- links: { flexDirection: 'row', justifyContent: 'center', paddingTop: 14 },
smallLink: { color: '#9a9fac', fontSize: 12 },
header: { height: 54, alignItems: 'center', justifyContent: 'center' },
headerTitle: { fontSize: 16, color: ink, fontWeight: '700' },
@@ -436,25 +200,6 @@ const s = StyleSheet.create({
backText: { fontSize: 28, color: '#454954' },
content: { flex: 1 },
contentInner: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 24 },
- heading: { color: ink, fontSize: 18, fontWeight: '700', marginBottom: 22, letterSpacing: -0.5 },
- termRow: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 16 },
- allRow: { paddingBottom: 18, marginBottom: 10, borderBottomWidth: 1, borderColor: '#d2d5dc' },
- termChoice: { flex: 1, flexDirection: 'row', gap: 16, alignItems: 'center', minHeight: 44 },
- termText: { fontSize: 14, fontWeight: '600', color: '#242424' },
- checkbox: {
- width: 17,
- height: 17,
- borderWidth: 1,
- borderColor: '#e0e5f1',
- borderRadius: 4,
- alignItems: 'center',
- justifyContent: 'center',
- },
- checked: { backgroundColor: '#219bff', borderColor: '#219bff' },
- checkMark: { fontSize: 12, color: '#fff', fontWeight: '700' },
- viewTerm: { minHeight: 44, minWidth: 32, justifyContent: 'center', alignItems: 'flex-end' },
- underlined: { fontSize: 12, color: '#9a9fac', textDecorationLine: 'underline' },
- footer: { paddingHorizontal: 24, paddingBottom: 38, paddingTop: 16 },
primary: {
height: 56,
borderRadius: 4,
@@ -466,29 +211,6 @@ const s = StyleSheet.create({
disabled: { backgroundColor: '#d6d8dc' },
disabledText: { color: '#949aa4' },
pressed: { opacity: 0.7 },
- label: { color: ink, fontSize: 12, marginBottom: 12 },
- input: {
- height: 60,
- borderWidth: 1,
- borderColor: '#d2d5dc',
- borderRadius: 4,
- paddingHorizontal: 16,
- fontSize: 15,
- color: ink,
- backgroundColor: '#fcfcff',
- },
- subjectLabel: { fontSize: 13, color: ink, marginTop: 18, marginBottom: 10, marginLeft: 8 },
- chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, width: 200, paddingLeft: 4 },
- chip: {
- paddingHorizontal: 17,
- height: 36,
- borderRadius: 20,
- backgroundColor: '#e9ebf4',
- justifyContent: 'center',
- },
- chipText: { fontSize: 14, color: ink },
- selectedChip: { backgroundColor: '#219bff' },
- selectedChipText: { color: '#fff', fontWeight: '600' },
profileRow: {
borderBottomWidth: 1,
borderColor: '#d2d5dc',
@@ -523,5 +245,4 @@ const s = StyleSheet.create({
dialogActions: { flexDirection: 'row', gap: 12 },
cancel: { flex: 1, alignItems: 'center', justifyContent: 'center' },
flex: { flex: 1 },
- demoButton: { alignItems: 'center', padding: 16, marginBottom: 8 },
});
diff --git a/src/features/profile/ProfileScreen.tsx b/src/features/profile/ProfileScreen.tsx
new file mode 100644
index 0000000..6c9d6fb
--- /dev/null
+++ b/src/features/profile/ProfileScreen.tsx
@@ -0,0 +1,230 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ Keyboard,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { ActionButton } from '@/components/ActionButton';
+import { Chip } from '@/components/Chip';
+import { Loading } from '@/components/Loading';
+import { TextInput } from '@/components/TextInput';
+import { useOnboarding } from '@/features/onboarding/OnboardingProvider';
+import { semantic } from '@/styles/colors';
+import { gap, grid, lineWeight, padding } from '@/styles/measurement';
+import { typography } from '@/styles/typography';
+import { isValidProfileName, profileSubjects } from './profile';
+import type { ProfileValue, SaveProfile } from './profile';
+
+type Props = { onSave?: SaveProfile; initialProfile?: ProfileValue };
+export default function ProfileScreen({ onSave, initialProfile }: Props) {
+ const router = useRouter();
+ const { name, setName, selected, setSelected, setProfile, editing } = useOnboarding();
+ const [draftName, setDraftName] = useState(initialProfile?.name ?? name);
+ const [draftSubjects, setDraftSubjects] = useState(initialProfile?.subjects ?? selected);
+ const [touched, setTouched] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const inFlight = useRef(false);
+ const mounted = useRef(true);
+ const controller = useRef(null);
+ const validName = isValidProfileName(draftName);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ controller.current?.abort();
+ };
+ }, []);
+
+ async function submit() {
+ if (!validName || inFlight.current) return;
+ inFlight.current = true;
+ setFailed(false);
+ Keyboard.dismiss();
+ const request = new AbortController();
+ controller.current = request;
+ const value = { name: draftName.trim(), subjects: [...draftSubjects] };
+ try {
+ if (onSave) {
+ setSaving(true);
+ await onSave(value, request.signal);
+ }
+ if (!mounted.current || request.signal.aborted) return;
+ // API 미확정: 기본 동작은 메모리 상태 반영만 수행합니다.
+ setName(value.name);
+ setSelected(value.subjects);
+ setProfile(value);
+ if (editing) {
+ if (router.canGoBack()) router.back();
+ else router.replace('/settings');
+ } else {
+ if (router.canDismiss()) router.dismissAll();
+ router.replace('/home');
+ }
+ } catch {
+ if (mounted.current && !request.signal.aborted) setFailed(true);
+ } finally {
+ inFlight.current = false;
+ if (mounted.current) setSaving(false);
+ }
+ }
+
+ function back() {
+ if (inFlight.current) return;
+ Keyboard.dismiss();
+ setName(draftName);
+ setSelected([...draftSubjects]);
+ if (router.canGoBack()) router.back();
+ else router.replace(editing ? '/settings' : '/terms');
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 프로필
+
+
+
+
+ 학부모님께 보일 이름을 알려 주세요
+
+ 이름*
+ {
+ setDraftName(value);
+ setFailed(false);
+ }}
+ onSubmitEditing={() => {
+ if (!validName) setTouched(true);
+ else void submit();
+ }}
+ errorMessage={
+ touched && draftName.length > 0 && !validName
+ ? '이름은 2~10자로 입력해 주세요.'
+ : undefined
+ }
+ />
+ 과목 (선택, 여러 개)
+
+ {profileSubjects.map((subject) => (
+ {
+ setFailed(false);
+ setDraftSubjects((current) =>
+ current.includes(subject)
+ ? current.filter((item) => item !== subject)
+ : [...current, subject],
+ );
+ }}
+ />
+ ))}
+
+
+
+ {failed && (
+
+ 연결이 불안정해요. 다시 시도해 주세요.
+
+ )}
+ void submit()}
+ />
+
+
+
+
+ );
+}
+const s = StyleSheet.create({
+ safeArea: { flex: 1, alignItems: 'center', backgroundColor: semantic.surface.input },
+ phone: { flex: 1, width: '100%', maxWidth: 390, paddingTop: Platform.OS === 'web' ? 44 : 0 },
+ header: { height: 54, alignItems: 'center', justifyContent: 'center' },
+ headerTitle: { ...typography.body[1].semiBold, color: semantic.text.default.strong },
+ back: {
+ position: 'absolute',
+ left: 12,
+ width: 44,
+ height: 44,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ backIcon: { width: 16, height: 16, justifyContent: 'center' },
+ arrowHead: {
+ position: 'absolute',
+ left: 1,
+ width: 10,
+ height: 10,
+ borderLeftWidth: lineWeight.semiBold,
+ borderBottomWidth: lineWeight.semiBold,
+ borderColor: semantic.icon.primary,
+ transform: [{ rotate: '45deg' }],
+ },
+ arrowLine: { height: lineWeight.semiBold, width: 16, backgroundColor: semantic.icon.primary },
+ scroll: { flex: 1 },
+ content: { paddingHorizontal: grid.margin, paddingTop: padding[20], paddingBottom: padding[24] },
+ title: {
+ ...typography.heading[1].semiBold,
+ color: semantic.text.default.strong,
+ marginBottom: gap[20],
+ },
+ nameLabel: {
+ ...typography.body[5].medium,
+ color: semantic.text.default.normal,
+ marginLeft: padding[8],
+ marginBottom: gap[8],
+ },
+ subjectLabel: {
+ ...typography.body[5].medium,
+ color: semantic.text.default.normal,
+ marginTop: gap[16],
+ marginBottom: gap[8],
+ marginLeft: padding[4],
+ },
+ chips: { flexDirection: 'row', flexWrap: 'wrap', gap: gap[8] },
+ footer: { paddingHorizontal: grid.margin, paddingTop: padding[16], paddingBottom: 56 },
+ error: { marginBottom: gap[8] },
+ errorText: { ...typography.caption[1].regular, color: semantic.text.feedback.warning },
+});
diff --git a/src/features/profile/profile.ts b/src/features/profile/profile.ts
new file mode 100644
index 0000000..c2efb4c
--- /dev/null
+++ b/src/features/profile/profile.ts
@@ -0,0 +1,8 @@
+export const profileSubjects = ['국어', '영어', '수학', '과학', '사회', '기타'] as const;
+export type ProfileValue = { name: string; subjects: string[] };
+/** 화면 모델입니다. API 확정 후 요청 형식으로 변환합니다. */
+export type SaveProfile = (profile: ProfileValue, signal: AbortSignal) => Promise;
+export function isValidProfileName(value: string) {
+ const length = value.trim().length;
+ return length >= 2 && length <= 10;
+}
diff --git a/src/features/terms/TermsCheckbox.tsx b/src/features/terms/TermsCheckbox.tsx
new file mode 100644
index 0000000..bbda579
--- /dev/null
+++ b/src/features/terms/TermsCheckbox.tsx
@@ -0,0 +1,55 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { semantic } from '@/styles/colors';
+import { gap, lineWeight, radius } from '@/styles/measurement';
+import { typography } from '@/styles/typography';
+
+type Props = { label: string; checked: boolean; disabled: boolean; onPress: () => void };
+export function TermsCheckbox({ label, checked, disabled, onPress }: Props) {
+ return (
+ [s.row, pressed && s.pressed]}
+ >
+
+ {checked && }
+
+ {label}
+
+ );
+}
+const s = StyleSheet.create({
+ row: {
+ flex: 1,
+ minHeight: 42,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: gap[8],
+ paddingLeft: 2,
+ },
+ pressed: { opacity: 0.7 },
+ box: {
+ width: 16,
+ height: 16,
+ borderRadius: radius.small,
+ borderWidth: lineWeight.regular,
+ borderColor: semantic.border.default,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ checked: { backgroundColor: semantic.icon.brand, borderColor: semantic.icon.brand },
+ check: {
+ width: 8,
+ height: 5,
+ borderLeftWidth: 1.5,
+ borderBottomWidth: 1.5,
+ borderColor: semantic.text.default.inverse,
+ transform: [{ rotate: '-45deg' }],
+ marginTop: -2,
+ },
+ label: { ...typography.body[3].regular, color: semantic.text.default.normal, flexShrink: 1 },
+});
diff --git a/src/features/terms/TermsScreen.tsx b/src/features/terms/TermsScreen.tsx
new file mode 100644
index 0000000..654a275
--- /dev/null
+++ b/src/features/terms/TermsScreen.tsx
@@ -0,0 +1,275 @@
+import { useEffect, useRef, useState } from 'react';
+import { Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { ActionButton } from '@/components/ActionButton';
+import { Loading } from '@/components/Loading';
+import { useOnboarding } from '@/features/onboarding/OnboardingProvider';
+import { semantic } from '@/styles/colors';
+import { gap, grid, lineWeight, padding, radius } from '@/styles/measurement';
+import { typography } from '@/styles/typography';
+import { TermsCheckbox } from './TermsCheckbox';
+import { termsItems } from './terms';
+import type { SaveTerms, TermId, TermsDocuments } from './terms';
+
+type Props = {
+ /** API 확정 전에는 생략합니다. 이 경우 동의를 메모리에 반영하고 이동할 뿐 서버에 저장하지 않습니다. */
+ onSave?: SaveTerms;
+ documents?: TermsDocuments;
+ initialChecked?: boolean;
+};
+
+export default function TermsScreen({ onSave, documents, initialChecked = false }: Props) {
+ const router = useRouter();
+ const { agreed, setAgreed } = useOnboarding();
+ const [draft, setDraft] = useState(() =>
+ termsItems.map((_, i) => initialChecked || agreed[i] || false),
+ );
+ const [saving, setSaving] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const [documentId, setDocumentId] = useState(null);
+ const inFlight = useRef(false);
+ const mounted = useRef(true);
+ const controller = useRef(null);
+ const allChecked = draft.every(Boolean);
+ const document = termsItems.find((item) => item.id === documentId);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ controller.current?.abort();
+ };
+ }, []);
+
+ function toggle(index?: number) {
+ if (inFlight.current) return;
+ setFailed(false);
+ setDraft((current) =>
+ index === undefined
+ ? current.map(() => !current.every(Boolean))
+ : current.map((value, i) => (i === index ? !value : value)),
+ );
+ }
+
+ async function submit() {
+ if (!allChecked || inFlight.current) return;
+ inFlight.current = true;
+ setFailed(false);
+ const request = new AbortController();
+ controller.current = request;
+ const selection = { service: draft[0], privacy: draft[1], contacts: draft[2] };
+ try {
+ if (onSave) {
+ setSaving(true);
+ await onSave(selection, request.signal);
+ }
+ if (!mounted.current || request.signal.aborted) return;
+ setAgreed([...draft]);
+ router.push('/profile');
+ } catch {
+ if (mounted.current && !request.signal.aborted) setFailed(true);
+ } finally {
+ inFlight.current = false;
+ if (mounted.current) setSaving(false);
+ }
+ }
+
+ function back() {
+ if (inFlight.current) return;
+ // 뒤로 이동 후 다시 진입해도 선택은 유지합니다. 서버 저장 완료를 뜻하지 않습니다.
+ setAgreed([...draft]);
+ if (router.canGoBack()) router.back();
+ }
+
+ return (
+
+
+
+ {router.canGoBack() && (
+
+
+
+ )}
+
+ 약관 동의
+
+
+
+
+ 수담을 쓰려면 동의가 필요해요
+
+
+ toggle()}
+ />
+
+ {termsItems.map((item, index) => (
+
+ toggle(index)}
+ />
+ setDocumentId(item.id)}
+ style={s.viewButton}
+ >
+ 보기
+
+
+ ))}
+
+
+ void submit()}
+ />
+
+ {failed && (
+
+ 연결이 불안정해요
+ void submit()}
+ style={s.retry}
+ >
+ 다시 시도
+
+
+ )}
+
+
+
+
+ setDocumentId(null)}
+ >
+
+
+
+ {document?.label}
+
+
+
+ {(documentId && documents?.[documentId]) ||
+ '약관 원문이 아직 등록되지 않았어요. 현재 화면은 구현 확인용이며, 실제 가입 전에는 확정된 약관 원문을 연결해야 해요.'}
+
+
+ setDocumentId(null)} />
+
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ safeArea: { flex: 1, backgroundColor: semantic.surface.input, alignItems: 'center' },
+ phone: { flex: 1, width: '100%', maxWidth: 390, paddingTop: Platform.OS === 'web' ? 44 : 0 },
+ header: { height: 72, alignItems: 'center', justifyContent: 'center' },
+ headerTitle: { ...typography.body[1].semiBold, color: semantic.text.default.strong },
+ back: {
+ position: 'absolute',
+ left: 12,
+ width: 44,
+ height: 44,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ arrow: {
+ width: 12,
+ height: 12,
+ borderLeftWidth: lineWeight.semiBold,
+ borderBottomWidth: lineWeight.semiBold,
+ borderColor: semantic.icon.primary,
+ transform: [{ rotate: '45deg' }],
+ },
+ scroll: { flex: 1 },
+ content: { paddingHorizontal: grid.margin, paddingBottom: padding[24] },
+ title: {
+ ...typography.heading[1].semiBold,
+ color: semantic.text.default.strong,
+ marginBottom: gap[8],
+ },
+ allRow: {
+ minHeight: 48,
+ borderBottomWidth: lineWeight.regular,
+ borderColor: semantic.border.default,
+ marginBottom: gap[8],
+ },
+ termRow: { minHeight: 42, flexDirection: 'row', alignItems: 'center' },
+ viewButton: {
+ minWidth: 44,
+ minHeight: 42,
+ justifyContent: 'center',
+ alignItems: 'flex-end',
+ paddingRight: padding[4],
+ },
+ viewText: {
+ ...typography.body[4].regular,
+ color: semantic.text.default.weak,
+ textDecorationLine: 'underline',
+ },
+ footer: { paddingHorizontal: grid.margin, paddingTop: padding[16] },
+ feedbackSlot: {
+ height: 56,
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ paddingBottom: padding[4],
+ },
+ toast: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: semantic.surface.inverse,
+ borderRadius: radius.full,
+ paddingLeft: padding[12],
+ gap: gap[8],
+ },
+ toastText: { ...typography.caption[1].regular, color: semantic.text.default.inverse },
+ retry: { minHeight: 44, justifyContent: 'center', paddingRight: padding[12] },
+ retryText: {
+ ...typography.caption[1].semiBold,
+ color: semantic.text.default.inverse,
+ textDecorationLine: 'underline',
+ },
+ modalBackdrop: {
+ flex: 1,
+ backgroundColor: '#00000066',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: padding[24],
+ },
+ documentDialog: {
+ width: '100%',
+ maxWidth: 390,
+ maxHeight: '80%',
+ backgroundColor: semantic.surface.input,
+ borderRadius: radius.large,
+ padding: padding[24],
+ gap: gap[16],
+ },
+ documentTitle: { ...typography.heading[1].semiBold, color: semantic.text.default.strong },
+ documentScroll: { flexShrink: 1 },
+ documentBody: { paddingBottom: padding[8] },
+ documentText: { ...typography.body[3].regular, color: semantic.text.default.normal },
+});
diff --git a/src/features/terms/terms.ts b/src/features/terms/terms.ts
new file mode 100644
index 0000000..562780d
--- /dev/null
+++ b/src/features/terms/terms.ts
@@ -0,0 +1,11 @@
+export const termsItems = [
+ { id: 'service', label: '이용약관' },
+ { id: 'privacy', label: '개인정보 수집·이용' },
+ { id: 'contacts', label: '학부모·학생 연락처 처리' },
+] as const;
+
+export type TermId = (typeof termsItems)[number]['id'];
+/** 화면 내부 모델입니다. 백엔드 요청 형식은 API 확정 후 변환합니다. */
+export type TermsConsent = Record;
+export type SaveTerms = (consent: TermsConsent, signal: AbortSignal) => Promise;
+export type TermsDocuments = Partial>;