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/assets/images/record-success.svg b/assets/images/record-success.svg
new file mode 100644
index 0000000..920316f
--- /dev/null
+++ b/assets/images/record-success.svg
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/session-logo.svg b/assets/images/session-logo.svg
new file mode 100644
index 0000000..45e58a9
--- /dev/null
+++ b/assets/images/session-logo.svg
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/session-success.svg b/assets/images/session-success.svg
new file mode 100644
index 0000000..df87fb5
--- /dev/null
+++ b/assets/images/session-success.svg
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/session-record.md b/docs/session-record.md
new file mode 100644
index 0000000..f2e93f4
--- /dev/null
+++ b/docs/session-record.md
@@ -0,0 +1,48 @@
+# T-10 회차 기록 · 휴강 시트 · 휴강 완료
+
+## 구현 범위
+
+제공된 회차 기록 6개, 휴강 시트 2개, 휴강 완료 2개 SVG를 기준으로 구현했습니다. 홈·과외 생성 등 다른 담당 화면은 수정하지 않았습니다.
+
+- `SessionRecordScreen`: 교재별 진도, 직접 입력, 새 교재 등록·삭제, 이해도, 지난 숙제 수행, 다음 숙제, 관찰 한줄
+- 교재 진도(또는 안 함), 이해도, 지난 숙제 수행이 필수입니다. 등록 중인 교재가 있으면 저장을 막습니다.
+- 지난 진도가 없는 교재는 제안 칩 대신 직접 입력을 표시합니다. 교재가 하나도 없는 경우 등록 폼으로 시작합니다.
+- 새 교재는 이름과 진도 단위를 입력해 등록하며 시작 진도는 선택 사항입니다. API 확정 전 화면 입력 규칙입니다.
+- 저장 중 입력·선택·중복 제출 차단, 실패 시 전체 입력 유지 및 다시 저장
+- `CancellationSheet`: 사유 단일 선택, 배경 터치·기기 뒤로 가기로 닫기, 저장 중 닫기 차단, 실패 후 재시도
+- `CancellationComplete`: 저장 결과의 인정 회차·사유 표시, 학부모 수신 동의가 없으면 보강 조율 버튼 미노출
+- `ActionButton`, `Chip`, `TextInput`, `Loading` 재사용. 균등 폭 선택 그룹과 유동 폭 진도 막대는 별도 구현했습니다. 공통 SelectButton·ProgressBar는 고정 너비라 이 화면 배치와 맞지 않습니다.
+
+## API와 다른 화면 연결
+
+실제 API·저장·알림 발송·보강 일정 생성은 연결하지 않았습니다. 서비스에서 사용할 때 `LessonContext`, `onSaveRecord`, `onSaveCancellation`, `onHome`, `onCoordinate`를 전달합니다. 저장 콜백은 필수이며 암묵적으로 성공을 반환하지 않습니다. `CancellationResult`의 회차 인정·누적 회차는 백엔드 응답을 연결하도록 구분했습니다.
+
+화면 모델은 백엔드 계약이 아닙니다. `preview.tsx`만 확인용 학생·교재·날짜와 지연/실패를 생성합니다. 회차 인정 예시는 SVG 문구에 따라 학생 당일 취소 + 인정 정책인 경우만 증가합니다. 실제 정책은 백엔드 확인이 필요합니다. 시안의 숫자는 예시이므로 완료 화면의 숫자는 전달받은 결과를 표시합니다.
+
+## 개발 전용 확인 경로
+
+- `/dev/session-record`: 첫 수업, 시안의 선택 예시
+- `/dev/session-record?variant=blank`: 필수 항목 미선택, 저장 비활성
+- `/dev/session-record?variant=empty`: 교재·지난 진도 없음, 직접 등록/입력
+- `/dev/session-record?variant=later`: 2회차
+- `/dev/session-record?variant=direct`: 다음 숙제 직접 입력
+- `/dev/session-record?scenario=loading`: 저장 시 3초 로딩
+- `/dev/session-record?scenario=error`: 첫 저장 실패, 다시 저장 성공
+- `/dev/session-cancellation`: 사유 미선택 시트
+- `/dev/session-cancellation?scenario=loading`: 사유 선택 후 저장 중
+- `/dev/session-cancellation?policy=count`: 학생 당일 취소 시 회차 인정 예시
+- `/dev/session-cancellation?scenario=error`: 첫 저장 실패 후 재시도
+- `/dev/session-complete`: 수신 동의 있는 완료 화면
+- `/dev/session-complete?consent=no`: 학부모 수신 동의 전
+- `/dev/session-complete?policy=count&reason=sameDay`: 회차 인정 예시
+
+모든 개발 경로는 production에서 `/home`으로 이동합니다. 실제 저장 성공·메시지 전송을 검증하는 기능이 아닙니다. 새 기능은 로그인·홈 흐름에 연결하지 않았습니다.
+
+## 기록 완료 화면 v4
+
+- 수업 저장 성공 시 팝업 대신 RecordComplete로 이동합니다. 실패 시에는 입력 화면을 유지합니다.
+- `/dev/session-record-complete`: 4회차 완료·인정 8/8·정산 준비 완료 예시를 바로 확인합니다.
+- 완료 회차·학생명·숙제는 전달된 데이터로 표시하며 정산 준비 여부는 저장 결과의 reportReady를 따릅니다.
+- 다음 숙제가 없으면 학생용 카카오 공유 버튼을 비활성화합니다. 정산이 준비되지 않으면 정산 리포트 버튼을 비활성화합니다.
+- 카카오 SDK·공유 템플릿·홈 알림 저장·정산 리포트 생성은 미연결입니다. 미리보기 버튼은 미연동 안내만 표시하며 실제 공유·청구를 수행하지 않습니다.
+- 실제 서비스 연결 시 onShare 및 onReport 콜백을 전달해야 합니다.
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/session-cancellation.tsx b/src/app/dev/session-cancellation.tsx
new file mode 100644
index 0000000..009f0e7
--- /dev/null
+++ b/src/app/dev/session-cancellation.tsx
@@ -0,0 +1,4 @@
+import { SessionPreview } from '@/features/session-record/preview';
+export default function Screen() {
+ return ;
+}
diff --git a/src/app/dev/session-complete.tsx b/src/app/dev/session-complete.tsx
new file mode 100644
index 0000000..127b829
--- /dev/null
+++ b/src/app/dev/session-complete.tsx
@@ -0,0 +1,4 @@
+import { SessionPreview } from '@/features/session-record/preview';
+export default function Screen() {
+ return ;
+}
diff --git a/src/app/dev/session-record-complete.tsx b/src/app/dev/session-record-complete.tsx
new file mode 100644
index 0000000..9a70a09
--- /dev/null
+++ b/src/app/dev/session-record-complete.tsx
@@ -0,0 +1,4 @@
+import { SessionPreview } from '@/features/session-record/preview';
+export default function Screen() {
+ return ;
+}
diff --git a/src/app/dev/session-record.tsx b/src/app/dev/session-record.tsx
new file mode 100644
index 0000000..7d24d20
--- /dev/null
+++ b/src/app/dev/session-record.tsx
@@ -0,0 +1,4 @@
+import { SessionPreview } from '@/features/session-record/preview';
+export default function Screen() {
+ 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/session-record/BookEditor.tsx b/src/features/session-record/BookEditor.tsx
new file mode 100644
index 0000000..0ca7e4b
--- /dev/null
+++ b/src/features/session-record/BookEditor.tsx
@@ -0,0 +1,114 @@
+import { useState } from 'react';
+import { Pressable, Text, View } from 'react-native';
+import { Chip } from '@/components/Chip';
+import { TextInput } from '@/components/TextInput';
+import { s } from './styles';
+import type { Textbook } from './types';
+const units = ['Unit', '단원', '페이지', '회', '직접'] as const;
+type Props = {
+ number: number;
+ disabled: boolean;
+ onRegister: (book: Omit) => void;
+ onCancel: () => void;
+};
+export function BookEditor({ number, disabled, onRegister, onCancel }: Props) {
+ const [name, setName] = useState('');
+ const [unit, setUnit] = useState('');
+ const [customUnit, setCustomUnit] = useState('');
+ const [start, setStart] = useState('');
+ const [error, setError] = useState(false);
+ const unitName = unit === '직접' ? customUnit.trim() : unit;
+ function register() {
+ if (disabled) return;
+ if (!name.trim() || !unitName) {
+ setError(true);
+ return;
+ }
+ onRegister({
+ name: name.trim(),
+ unit: unitName,
+ previousProgress: start.trim() || undefined,
+ suggestions: [],
+ });
+ }
+ return (
+
+
+ 교재 {number}
+
+
+ 취소
+
+
+ 등록하기
+
+
+
+
+
+ 교재명*
+
+
+
+ 진도 단위
+
+ {units.map((v) => (
+ setUnit(v)}
+ />
+ ))}
+
+
+ {
+ setUnit('직접');
+ setCustomUnit(v);
+ }}
+ editable={!disabled}
+ maxLength={20}
+ placeholder="텍스트를 입력해주세요"
+ />
+
+
+ 시작 진도(선택)
+
+
+ {error && (
+
+ 교재명과 진도 단위를 입력해 주세요.
+
+ )}
+
+
+ );
+}
diff --git a/src/features/session-record/CancellationComplete.tsx b/src/features/session-record/CancellationComplete.tsx
new file mode 100644
index 0000000..387201c
--- /dev/null
+++ b/src/features/session-record/CancellationComplete.tsx
@@ -0,0 +1,109 @@
+import { Image } from 'expo-image';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ActionButton } from '@/components/ActionButton';
+import { semantic as c } from '@/styles/colors';
+import { typography as t } from '@/styles/typography';
+import { s } from './styles';
+import type { CancellationResult } from './types';
+import logo from '../../../assets/images/session-logo.svg';
+import success from '../../../assets/images/session-success.svg';
+type Props = {
+ result: CancellationResult;
+ teacherLabel: string;
+ onHome: () => void;
+ onCoordinate: () => void;
+};
+export function CancellationComplete({ result, teacherLabel, onHome, onCoordinate }: Props) {
+ return (
+
+
+
+
+
+
+ {teacherLabel}
+
+
+
+
+
+ 휴강을 기록했어요
+
+ {result.reason}
+
+ {result.counted ? '회차로 인정돼요' : '회차로 인정되지 않아요'}
+
+
+
+
+ ✓
+
+ 인정 회차는 {result.counted ? '반영되어' : '그대로'} {result.completedCount}/
+ {result.totalCount}
+
+
+
+
+ ✓
+
+ {result.parentConsented
+ ? '보강 날짜를 학부모님께 물어볼 수 있어요'
+ : '보강 날짜 조율은 수신 동의 후 가능해요'}
+
+
+
+
+
+ {!result.parentConsented && (
+ 학부모 연락처 동의 후 보강 날짜를 물어볼 수 있어요.
+ )}
+
+ {result.parentConsented && (
+
+ )}
+
+
+
+ );
+}
+const v = StyleSheet.create({
+ content: { paddingHorizontal: 24, paddingBottom: 24 },
+ brandRow: {
+ height: 72,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: 12,
+ },
+ logo: { width: 136, height: 40 },
+ teacher: {
+ backgroundColor: c.surface.brand,
+ borderRadius: 999,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ flexShrink: 1,
+ },
+ teacherText: { ...t.caption[1].medium, color: c.text.brand.assistive },
+ hero: { alignItems: 'center', paddingTop: 26 },
+ success: { width: 80, height: 80, marginBottom: 12 },
+ title: { ...t.body[1].semiBold, color: c.text.default.neutral, marginBottom: 24 },
+ reason: { ...t.body[3].semiBold, color: c.text.brand.assistive, marginBottom: 4 },
+ subtitle: { ...t.body[4].regular, color: c.text.default.weak },
+ summary: {
+ marginTop: 72,
+ paddingHorizontal: 12,
+ backgroundColor: c.surface.subtle,
+ borderRadius: 8,
+ },
+ summaryRow: { minHeight: 56, flexDirection: 'row', gap: 16, alignItems: 'center' },
+ summaryText: { ...t.body[3].semiBold, color: c.text.brand.assistive, flex: 1 },
+ tick: { color: c.action.default, fontSize: 18 },
+ rule: { height: 1, backgroundColor: c.border.default },
+ consentHint: {
+ ...t.body[4].regular,
+ color: c.text.default.weak,
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+});
diff --git a/src/features/session-record/CancellationSheet.tsx b/src/features/session-record/CancellationSheet.tsx
new file mode 100644
index 0000000..f4e0daf
--- /dev/null
+++ b/src/features/session-record/CancellationSheet.tsx
@@ -0,0 +1,100 @@
+import { useState } from 'react';
+import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { ActionButton } from '@/components/ActionButton';
+import { Loading } from '@/components/Loading';
+import { Choices } from './Choices';
+import { s } from './styles';
+import { useSubmission } from './useSubmission';
+import type {
+ CancellationReason,
+ CancellationResult,
+ LessonContext,
+ SaveCancellation,
+} from './types';
+type Props = {
+ lesson: LessonContext;
+ onClose: () => void;
+ onSave: SaveCancellation;
+ onComplete: (result: CancellationResult) => void;
+};
+export function CancellationSheet({ lesson, onClose, onSave, onComplete }: Props) {
+ const [reason, setReason] = useState(null);
+ const submit = useSubmission();
+ const insets = useSafeAreaInsets();
+ function save() {
+ if (!reason || submit.pending) return;
+ const input = { lessonId: lesson.lessonId, reason };
+ void submit.run((signal) => onSave(input, signal), onComplete);
+ }
+ return (
+ <>
+ {
+ if (!submit.pending) onClose();
+ }}
+ >
+
+
+
+
+
+
+ 휴강으로 기록할까요?
+
+
+ 회차로 인정되는 경우는 '학생 당일 취소'이면서 휴강 정책이 '인정'일 때뿐이에요.{' '}
+ {lesson.studentName} 학생의 정책은{' '}
+ {lesson.sameDayCancellationCounts ? '인정' : '미인정'}입니다.
+
+
+
+
+
+ {submit.error && (
+
+
+ 저장하지 못했어요. 사유를 유지했으니 다시 시도해 주세요.
+
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/session-record/Choices.tsx b/src/features/session-record/Choices.tsx
new file mode 100644
index 0000000..997bc68
--- /dev/null
+++ b/src/features/session-record/Choices.tsx
@@ -0,0 +1,30 @@
+import { Pressable, Text, View } from 'react-native';
+import { s } from './styles';
+type Props = {
+ label: string;
+ options: readonly T[];
+ value: T | null;
+ disabled?: boolean;
+ onChange: (value: T) => void;
+};
+/** 세 칸/두 칸 균등 폭 선택. 고정 최소 너비인 공통 SelectButton과 달리 좁은 화면에서도 줄 안에 맞춥니다. */
+export function Choices({ label, options, value, disabled, onChange }: Props) {
+ return (
+
+ {options.map((option) => (
+ onChange(option)}
+ style={[s.choice, value === option && s.selectedChoice]}
+ >
+ {option}
+
+ ))}
+
+ );
+}
diff --git a/src/features/session-record/RecordComplete.tsx b/src/features/session-record/RecordComplete.tsx
new file mode 100644
index 0000000..b891e99
--- /dev/null
+++ b/src/features/session-record/RecordComplete.tsx
@@ -0,0 +1,169 @@
+import { Image } from 'expo-image';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ActionButton } from '@/components/ActionButton';
+import { semantic as c } from '@/styles/colors';
+import { typography as t } from '@/styles/typography';
+import { s } from './styles';
+import success from '../../../assets/images/record-success.svg';
+import type { LessonContext, RecordShareTarget, SessionRecord, SessionRecordResult } from './types';
+type Props = {
+ lesson: LessonContext;
+ record: SessionRecord;
+ result: SessionRecordResult;
+ onShare: (target: RecordShareTarget) => void;
+ onReport: () => void;
+ onHome: () => void;
+};
+export function RecordComplete({ lesson, record, result, onShare, onReport, onHome }: Props) {
+ const hasHomework = Boolean(record.nextHomework.trim());
+ return (
+
+
+
+
+
+
+ {lesson.sessionNumber}회차 기록 완료
+
+
+ 카톡으로 보내기
+
+
+ {lesson.studentName} 학생 오늘 근황
+ {lesson.studentName} 어머님
+
+ onShare('parent-summary')}
+ />
+
+
+
+ 다음 숙제
+
+ {hasHomework ? `${lesson.studentName} 학생` : '등록된 다음 숙제가 없어요'}
+
+
+ onShare('student-homework')}
+ />
+
+
+ ⓘ
+
+ 카카오톡 공유 창에서 받는 사람을 고르면 보내져요.{'\n'}안 보내면 홈 알림에 남아요.
+
+
+ 완료 내역
+
+
+ ✓
+ 학부모님께 보낼 오늘 근황 카드를 만들었어요
+
+
+ ✓
+
+ {hasHomework
+ ? `학생에게 보낼 숙제 알림 카드를 만들었어요\n· ${record.nextHomework}`
+ : '다음 숙제는 등록하지 않았어요'}
+
+
+ {result.reportReady ? (
+
+ ✓
+
+ 정산 {result.totalCount}회차가 채워져 정산 리포트와 교육비 안내를 만들었어요
+
+
+ ) : (
+
+ ✓
+
+ 인정 회차 {result.completedCount}/{result.totalCount}를 기록했어요
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+const v = StyleSheet.create({
+ content: { paddingHorizontal: 24, paddingBottom: 52, flexGrow: 1 },
+ hero: { alignItems: 'center', paddingTop: 36, marginBottom: 28 },
+ success: { width: 90, height: 90, marginBottom: 16 },
+ title: { ...t.body[1].semiBold, color: c.text.brand.assistive },
+ sectionLabel: {
+ ...t.body[5].medium,
+ color: c.text.default.normal,
+ marginLeft: 8,
+ marginBottom: 12,
+ },
+ shareCard: {
+ minHeight: 82,
+ backgroundColor: c.surface.subtle,
+ borderRadius: 8,
+ padding: 16,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 12,
+ marginBottom: 4,
+ },
+ shareDescription: { flex: 1, gap: 4 },
+ cardTitle: { ...t.body[3].semiBold, color: c.text.default.normal },
+ recipient: { ...t.body[5].regular, color: c.text.default.normal },
+ info: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ padding: 12,
+ backgroundColor: c.surface.brand,
+ borderWidth: 1,
+ borderColor: c.border.focused,
+ borderRadius: 4,
+ marginTop: 8,
+ marginBottom: 20,
+ },
+ infoIcon: { color: c.text.brand.assistive, fontSize: 14 },
+ infoText: { ...t.caption[1].regular, color: c.text.brand.assistive, flex: 1 },
+ detailsLabel: {
+ ...t.body[5].medium,
+ color: c.text.default.normal,
+ marginLeft: 8,
+ marginBottom: 12,
+ },
+ details: { backgroundColor: c.surface.subtle, paddingHorizontal: 12, borderRadius: 8 },
+ detailRow: {
+ minHeight: 56,
+ paddingHorizontal: 4,
+ paddingVertical: 16,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 16,
+ borderBottomWidth: 1,
+ borderColor: c.border.default,
+ },
+ lastRow: { borderBottomWidth: 0 },
+ tick: { fontSize: 18, color: c.action.default },
+ detailText: { ...t.body[4].semiBold, color: c.text.brand.assistive, flex: 1 },
+ spacer: { flexGrow: 1, minHeight: 40 },
+ actions: { gap: 8 },
+});
diff --git a/src/features/session-record/SessionRecordScreen.tsx b/src/features/session-record/SessionRecordScreen.tsx
new file mode 100644
index 0000000..284c205
--- /dev/null
+++ b/src/features/session-record/SessionRecordScreen.tsx
@@ -0,0 +1,436 @@
+import { useRef, useState } from 'react';
+import {
+ Keyboard,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ Text,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ActionButton } from '@/components/ActionButton';
+import { Chip } from '@/components/Chip';
+import { Loading } from '@/components/Loading';
+import { TextInput } from '@/components/TextInput';
+import { BookEditor } from './BookEditor';
+import { Choices } from './Choices';
+import { CancellationSheet } from './CancellationSheet';
+import { CancellationComplete } from './CancellationComplete';
+import { RecordComplete } from './RecordComplete';
+import { s } from './styles';
+import { useSubmission } from './useSubmission';
+import type {
+ BookProgress,
+ CancellationResult,
+ HomeworkStatus,
+ LessonContext,
+ SaveCancellation,
+ SaveRecord,
+ SessionRecordResult,
+ RecordShareTarget,
+ SessionRecord,
+ Textbook,
+ Understanding,
+} from './types';
+
+type Props = {
+ lesson: LessonContext;
+ onSaveRecord: SaveRecord;
+ onSaveCancellation: SaveCancellation;
+ onBack: () => void;
+ onHome: () => void;
+ onCoordinate: () => void;
+ onShare: (target: RecordShareTarget) => void;
+ onReport: () => void;
+ initialFilled?: boolean;
+ initialDirect?: boolean;
+ initialSheet?: boolean;
+ initialCompletion?: CancellationResult;
+ onRecorded?: (record: SessionRecord) => void;
+};
+export function SessionRecordScreen({
+ lesson,
+ onSaveRecord,
+ onSaveCancellation,
+ onBack,
+ onHome,
+ onCoordinate,
+ onShare,
+ onReport,
+ initialFilled = false,
+ initialDirect = false,
+ initialSheet = false,
+ initialCompletion,
+ onRecorded,
+}: Props) {
+ const [books, setBooks] = useState(lesson.textbooks);
+ const [progress, setProgress] = useState>(() =>
+ initialFilled
+ ? Object.fromEntries(
+ lesson.textbooks.map((book, i) => [book.id, book.suggestions[i === 1 ? 1 : 0] ?? '']),
+ )
+ : {},
+ );
+ const [customProgress, setCustomProgress] = useState>({});
+ const [understanding, setUnderstanding] = useState(
+ initialFilled ? '중' : null,
+ );
+ const selectedUnderstanding = understanding;
+ const [homework, setHomework] = useState(initialFilled ? '일부' : null);
+ const [nextHomework, setNextHomework] = useState(initialDirect ? '직접' : '');
+ const [customHomework, setCustomHomework] = useState('');
+ const [observation, setObservation] = useState('');
+ const [editingBook, setEditingBook] = useState(lesson.textbooks.length === 0);
+ const [sheet, setSheet] = useState(initialSheet);
+ const [completion, setCompletion] = useState(initialCompletion);
+ const [saved, setSaved] = useState<{ record: SessionRecord; result: SessionRecordResult } | null>(
+ null,
+ );
+ const [discard, setDiscard] = useState(false);
+ const nextBookId = useRef(0);
+ const submit = useSubmission();
+ const validProgress =
+ books.length > 0 &&
+ books.every(
+ (book) =>
+ Boolean(progress[book.id]) &&
+ (progress[book.id] !== '직접' || Boolean(customProgress[book.id]?.trim())),
+ );
+ const canSave =
+ validProgress && selectedUnderstanding !== null && homework !== null && !editingBook;
+ const dirty =
+ Object.values(progress).some(Boolean) ||
+ selectedUnderstanding !== null ||
+ homework !== null ||
+ Boolean(observation) ||
+ Boolean(nextHomework) ||
+ books.length !== lesson.textbooks.length ||
+ editingBook;
+ function save() {
+ if (!canSave || submit.pending || !selectedUnderstanding || !homework) return;
+ Keyboard.dismiss();
+ const entries: BookProgress[] = books.map((book) => ({
+ book: { ...book, suggestions: [...book.suggestions] },
+ value: progress[book.id] === '직접' ? customProgress[book.id].trim() : progress[book.id],
+ skipped: progress[book.id] === '안 함',
+ }));
+ const record: SessionRecord = {
+ lessonId: lesson.lessonId,
+ progress: entries,
+ understanding: selectedUnderstanding,
+ previousHomework: homework,
+ nextHomework:
+ nextHomework === '직접'
+ ? customHomework.trim()
+ : nextHomework === '없음'
+ ? ''
+ : nextHomework,
+ observation: observation.trim(),
+ };
+ void submit.run(
+ (signal) => onSaveRecord(record, signal),
+ (result) => {
+ setSaved({ record, result });
+ onRecorded?.(record);
+ },
+ );
+ }
+ function register(book: Omit) {
+ const id = `local-book-${++nextBookId.current}`;
+ setBooks((current) => [...current, { ...book, id }]);
+ setEditingBook(false);
+ submit.clearError();
+ }
+ function leave() {
+ if (submit.pending) return;
+ if (dirty) setDiscard(true);
+ else onBack();
+ }
+ if (saved)
+ return (
+
+ );
+ if (completion)
+ return (
+
+ );
+ return (
+
+
+
+
+ ←
+
+
+ 수업 기록
+
+ {
+ Keyboard.dismiss();
+ setSheet(true);
+ }}
+ style={s.leave}
+ >
+ 휴강
+
+
+
+ {lesson.studentName} 학생
+
+ {lesson.dateLabel}, {lesson.sessionNumber}회차
+
+
+ {Array.from({ length: lesson.totalCount }, (_, i) => (
+
+ ))}
+
+
+ 기록하면 정산{' '}
+
+ {lesson.sessionNumber}/{lesson.totalCount}
+
+ 회차가 돼요
+
+
+ 교재별 진도
+ {books.map((book) => (
+
+
+ {book.name}
+ {book.previousProgress && (
+ 지난 {book.previousProgress}
+ )}
+
+ {book.previousProgress && book.suggestions.length > 0 && (
+
+ {[
+ ...book.suggestions,
+ ...(book.unit === 'Unit' ? ['안 함', '직접'] : ['직접', '안 함']),
+ ].map((value) => (
+ {
+ submit.clearError();
+ setProgress((current) => ({
+ ...current,
+ [book.id]: current[book.id] === value ? '' : value,
+ }));
+ }}
+ />
+ ))}
+
+ )}
+ {(!book.previousProgress ||
+ book.suggestions.length === 0 ||
+ progress[book.id] === '직접') && (
+ {
+ submit.clearError();
+ setCustomProgress((current) => ({ ...current, [book.id]: value }));
+ setProgress((current) => ({ ...current, [book.id]: '직접' }));
+ }}
+ />
+ )}
+ {book.id.startsWith('local-book-') && (
+ setBooks((current) => current.filter((v) => v.id !== book.id))}
+ >
+ 교재 삭제
+
+ )}
+
+ ))}
+ {editingBook && (
+ setEditingBook(false)}
+ />
+ )}
+ setEditingBook(true)}
+ style={s.add}
+ >
+ +
+ 교재 추가
+
+
+
+ 이해도
+ {
+ submit.clearError();
+ setUnderstanding(value);
+ }}
+ />
+
+
+ 지난 숙제 수행
+ {
+ submit.clearError();
+ setHomework(value);
+ }}
+ />
+
+
+ 다음 숙제
+
+ {[...lesson.homeworkSuggestions, '없음', '직접'].map((value) => (
+ setNextHomework((current) => (current === value ? '' : value))}
+ />
+ ))}
+
+ {nextHomework === '직접' && (
+
+ 다음 숙제 직접 입력 (선택)
+
+
+ )}
+
+
+ 관찰 한줄 (선택)
+
+
+
+
+ {submit.error ? (
+
+ 다시 시도해주세요
+
+ ) : (
+ 진도·이해도·지난 숙제를 고르면 저장할 수 있어요
+ )}
+
+
+
+
+ {sheet && (
+ setSheet(false)}
+ onSave={onSaveCancellation}
+ onComplete={(result) => {
+ setSheet(false);
+ setCompletion(result);
+ }}
+ />
+ )}
+ {
+ setDiscard(false);
+ }}
+ >
+
+
+
+ 입력 중인 기록을 나갈까요?
+
+ 입력한 내용은 저장되지 않아요.
+ {discard && (
+ setDiscard(false)}
+ />
+ )}
+ {
+ setDiscard(false);
+ onBack();
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/src/features/session-record/preview.tsx b/src/features/session-record/preview.tsx
new file mode 100644
index 0000000..2d40c60
--- /dev/null
+++ b/src/features/session-record/preview.tsx
@@ -0,0 +1,177 @@
+import { useRef, useState } from 'react';
+import { Redirect, useLocalSearchParams, useRouter } from 'expo-router';
+import { Modal, Text, View } from 'react-native';
+import { ActionButton } from '@/components/ActionButton';
+import { SessionRecordScreen } from './SessionRecordScreen';
+import { RecordComplete } from './RecordComplete';
+import { s } from './styles';
+import type {
+ CancellationReason,
+ CancellationResult,
+ LessonContext,
+ SaveCancellation,
+ SaveRecord,
+} from './types';
+
+function wait(ms: number, signal: AbortSignal) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ signal.removeEventListener('abort', abort);
+ resolve();
+ }, ms);
+ function abort() {
+ clearTimeout(timer);
+ reject(new Error('Aborted'));
+ }
+ signal.addEventListener('abort', abort, { once: true });
+ if (signal.aborted) abort();
+ });
+}
+const reasons: Record = {
+ sameDay: '학생 당일 취소',
+ advance: '학생 사전 취소',
+ teacher: '선생님 사정',
+ holiday: '공휴일',
+};
+export function SessionPreview({
+ screen = 'record',
+}: {
+ screen?: 'record' | 'sheet' | 'complete' | 'record-complete';
+}) {
+ const params = useLocalSearchParams<{
+ variant?: string;
+ scenario?: string;
+ consent?: string;
+ policy?: string;
+ reason?: string;
+ }>();
+ const router = useRouter();
+ const [notice, setNotice] = useState<
+ 'coordinate' | 'parent-summary' | 'student-homework' | 'report' | null
+ >(null);
+ const recordAttempts = useRef(0);
+ const cancelAttempts = useRef(0);
+ if (!__DEV__) return ;
+ const variant = params.variant ?? 'first';
+ const empty = variant === 'empty';
+ const lesson: LessonContext = {
+ lessonId: 'preview-lesson',
+ studentName: screen === 'record-complete' ? '윤서아' : '박도윤',
+ dateLabel: '10월 14일 화요일',
+ sessionNumber:
+ screen === 'record-complete' ? 4 : variant === 'later' || variant === 'direct' ? 2 : 1,
+ completedCount:
+ screen === 'record-complete' ? 7 : variant === 'later' || variant === 'direct' ? 1 : 0,
+ totalCount: screen === 'complete' || screen === 'record-complete' ? 8 : 5,
+ sameDayCancellationCounts: params.policy === 'count',
+ parentConsented: params.consent !== 'no',
+ teacherLabel: 'OOO 선생님 · 영어',
+ textbooks: empty
+ ? []
+ : [
+ {
+ id: 'book-english',
+ name: '수능특강 영어',
+ unit: '강',
+ previousProgress: '6강',
+ suggestions: ['7강', '7-8강', '8-9강'],
+ },
+ {
+ id: 'book-grammar',
+ name: 'Grammar Zone 기본',
+ unit: 'Unit',
+ previousProgress: 'Unit 16',
+ suggestions: ['Unit 17', '17-18', '18-19'],
+ },
+ ],
+ homeworkSuggestions: ['2단원 예습', '워크북 42~45p'],
+ };
+ function result(reason: CancellationReason): CancellationResult {
+ const counted = reason === '학생 당일 취소' && lesson.sameDayCancellationCounts;
+ return {
+ reason,
+ counted,
+ completedCount: Math.min(lesson.completedCount + (counted ? 1 : 0), lesson.totalCount),
+ totalCount: lesson.totalCount,
+ parentConsented: lesson.parentConsented,
+ };
+ }
+ const saveRecord: SaveRecord = async (_, signal) => {
+ await wait(params.scenario === 'loading' ? 3000 : 650, signal);
+ if (params.scenario === 'error' && ++recordAttempts.current === 1)
+ throw new Error('Preview save failure');
+ const count = Math.min(lesson.completedCount + 1, lesson.totalCount);
+ return {
+ completedCount: count,
+ totalCount: lesson.totalCount,
+ reportReady: count >= lesson.totalCount,
+ };
+ };
+ const saveCancellation: SaveCancellation = async (input, signal) => {
+ await wait(params.scenario === 'loading' ? 3000 : 650, signal);
+ if (params.scenario === 'error' && ++cancelAttempts.current === 1)
+ throw new Error('Preview save failure');
+ return result(input.reason);
+ };
+ return (
+ <>
+ {screen === 'record-complete' ? (
+ setNotice('report')}
+ onHome={() => router.replace('/home')}
+ />
+ ) : (
+ router.replace('/home')}
+ onHome={() => router.replace('/home')}
+ onCoordinate={() => setNotice('coordinate')}
+ onShare={setNotice}
+ onReport={() => setNotice('report')}
+ initialFilled={variant !== 'blank' && !empty}
+ initialDirect={variant === 'direct'}
+ initialSheet={screen === 'sheet'}
+ initialCompletion={
+ screen === 'complete'
+ ? result(reasons[params.reason ?? 'teacher'] ?? '선생님 사정')
+ : undefined
+ }
+ />
+ )}
+ setNotice(null)}>
+
+
+
+ {notice === 'report'
+ ? '정산 리포트'
+ : notice === 'coordinate'
+ ? '보강 조율하기'
+ : '카카오 공유'}
+
+
+ {notice === 'coordinate'
+ ? '보강 날짜 조율 화면은 아직 연결되지 않았어요. 학부모에게 메시지를 전송하지 않았습니다.'
+ : notice === 'report'
+ ? '정산 리포트 화면은 아직 연결되지 않았어요. 정산·교육비 청구는 실행하지 않았습니다.'
+ : `카카오 공유는 아직 연결되지 않았어요. ${notice === 'parent-summary' ? '학부모용 오늘 근황' : '학생용 다음 숙제'} 카드 미리보기이며 실제 메시지는 전송하지 않았습니다.`}
+
+ setNotice(null)} />
+
+
+
+ >
+ );
+}
diff --git a/src/features/session-record/styles.ts b/src/features/session-record/styles.ts
new file mode 100644
index 0000000..6176efb
--- /dev/null
+++ b/src/features/session-record/styles.ts
@@ -0,0 +1,171 @@
+import { StyleSheet, Platform } from 'react-native';
+import { semantic as c } from '@/styles/colors';
+import { padding, grid, radius, gap } from '@/styles/measurement';
+import { typography as t } from '@/styles/typography';
+export const s = StyleSheet.create({
+ safe: { flex: 1, alignItems: 'center', backgroundColor: c.surface.input },
+ phone: { flex: 1, width: '100%', maxWidth: 390, paddingTop: Platform.OS === 'web' ? 44 : 0 },
+ header: { height: 72, alignItems: 'center', justifyContent: 'center' },
+ headerTitle: { ...t.body[1].semiBold, color: c.text.default.strong },
+ back: {
+ position: 'absolute',
+ left: padding[12],
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ backText: { color: c.icon.primary, fontSize: 26 },
+ leave: { position: 'absolute', right: grid.margin, minHeight: 44, justifyContent: 'center' },
+ link: { ...t.body[4].semiBold, color: c.text.brand.assistive },
+ scroll: { flex: 1 },
+ content: { paddingHorizontal: grid.margin, paddingBottom: padding[24] },
+ student: {
+ ...t.body[4].semiBold,
+ color: c.text.default.neutral,
+ marginBottom: gap[4],
+ marginLeft: padding[4],
+ },
+ lessonTitle: { ...t.heading[1].bold, color: c.text.default.strong, marginLeft: padding[4] },
+ blue: { color: c.text.brand.assistive },
+ track: {
+ flexDirection: 'row',
+ gap: gap[2],
+ marginTop: gap[20],
+ marginBottom: gap[12],
+ paddingHorizontal: padding[4],
+ },
+ segment: { height: 6, flex: 1, borderRadius: radius.full, backgroundColor: c.surface.disabled },
+ filledSegment: { backgroundColor: c.action.default },
+ progressText: {
+ ...t.caption[1].medium,
+ color: c.text.default.normal,
+ marginLeft: padding[4],
+ marginBottom: gap[24],
+ },
+ label: {
+ ...t.body[5].medium,
+ color: c.text.default.normal,
+ marginBottom: gap[8],
+ marginLeft: padding[8],
+ },
+ section: { marginBottom: gap[24] },
+ card: {
+ backgroundColor: c.surface.subtle,
+ borderRadius: radius.large,
+ padding: padding[12],
+ marginBottom: gap[12],
+ gap: gap[12],
+ },
+ bookHeading: { flexDirection: 'row', flexWrap: 'wrap', gap: gap[12], alignItems: 'center' },
+ bookName: { ...t.body[4].semiBold, color: c.text.default.neutral, flexShrink: 1 },
+ previous: { ...t.body[4].regular, color: c.text.default.weak },
+ chips: { flexDirection: 'row', flexWrap: 'wrap', gap: gap[8] },
+ add: {
+ minHeight: 56,
+ borderRadius: radius.medium,
+ backgroundColor: c.surface.default,
+ borderWidth: 1,
+ borderColor: c.border.default,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: gap[8],
+ },
+ addText: { ...t.body[3].regular, color: c.text.default.weak },
+ plus: { fontSize: 24, color: c.icon.secondary },
+ editor: {
+ backgroundColor: c.surface.subtle,
+ borderRadius: radius.large,
+ marginBottom: gap[12],
+ overflow: 'hidden',
+ },
+ editorHeader: {
+ padding: padding[16],
+ minHeight: 60,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ borderBottomWidth: 1,
+ borderColor: c.border.subtle,
+ },
+ editorBody: { padding: padding[16], gap: gap[16] },
+ editorActions: { flexDirection: 'row', gap: gap[16], alignItems: 'center' },
+ row: { flexDirection: 'row', gap: gap[4] },
+ choice: {
+ flex: 1,
+ minHeight: 46,
+ borderRadius: radius.medium,
+ borderWidth: 1,
+ borderColor: c.border.default,
+ backgroundColor: c.surface.input,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: padding[8],
+ paddingVertical: padding[8],
+ },
+ selectedChoice: { borderColor: c.border.focused, backgroundColor: c.surface.focused },
+ choiceText: { ...t.body[3].semiBold, color: c.text.default.weak, textAlign: 'center' },
+ selectedText: { color: c.text.default.strong },
+ footer: {
+ paddingHorizontal: grid.margin,
+ paddingTop: padding[8],
+ paddingBottom: Platform.OS === 'web' ? 56 : 24,
+ backgroundColor: c.surface.input,
+ gap: gap[12],
+ },
+ hint: { ...t.caption[1].medium, color: c.text.default.weak, textAlign: 'center' },
+ warning: { ...t.caption[1].regular, color: c.text.feedback.warning },
+ backdrop: {
+ flex: 1,
+ backgroundColor: '#2D303D80',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ },
+ sheet: {
+ width: '100%',
+ maxWidth: 390,
+ maxHeight: '92%',
+ backgroundColor: c.surface.subtle,
+ borderTopLeftRadius: radius.large,
+ borderTopRightRadius: radius.large,
+ paddingHorizontal: grid.margin,
+ paddingBottom: 56,
+ },
+ handle: {
+ width: 56,
+ height: 4,
+ borderRadius: radius.full,
+ backgroundColor: c.border.default,
+ alignSelf: 'center',
+ marginTop: gap[12],
+ marginBottom: gap[24],
+ },
+ sheetTitle: { ...t.heading[1].semiBold, color: c.text.default.strong, marginBottom: gap[8] },
+ description: { ...t.body[4].regular, color: c.text.default.weak, marginBottom: gap[16] },
+ reasonGrid: { gap: gap[4], marginBottom: gap[32] },
+ toast: {
+ backgroundColor: c.surface.inverse,
+ borderRadius: radius.full,
+ alignSelf: 'center',
+ paddingHorizontal: padding[12],
+ paddingVertical: padding[8],
+ },
+ toastText: { ...t.caption[1].regular, color: c.text.default.inverse },
+ confirmBackdrop: {
+ flex: 1,
+ backgroundColor: '#00000066',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: padding[24],
+ },
+ confirmCard: {
+ width: '100%',
+ maxWidth: 342,
+ borderRadius: radius.large,
+ backgroundColor: c.surface.input,
+ padding: padding[24],
+ gap: gap[16],
+ },
+ confirmTitle: { ...t.heading[1].semiBold, color: c.text.default.strong },
+});
diff --git a/src/features/session-record/types.ts b/src/features/session-record/types.ts
new file mode 100644
index 0000000..602809a
--- /dev/null
+++ b/src/features/session-record/types.ts
@@ -0,0 +1,55 @@
+export type Understanding = '상' | '중' | '하';
+export type HomeworkStatus = '완료' | '일부' | '안함';
+export type CancellationReason = '학생 당일 취소' | '학생 사전 취소' | '선생님 사정' | '공휴일';
+export type Textbook = {
+ id: string;
+ name: string;
+ unit: string;
+ previousProgress?: string;
+ suggestions: string[];
+};
+export type LessonContext = {
+ lessonId: string;
+ studentName: string;
+ dateLabel: string;
+ sessionNumber: number;
+ completedCount: number;
+ totalCount: number;
+ sameDayCancellationCounts: boolean;
+ parentConsented: boolean;
+ teacherLabel: string;
+ textbooks: Textbook[];
+ homeworkSuggestions: string[];
+};
+/** 화면 내부 모델이며 확정된 API 요청·응답 형식이 아닙니다. */
+export type BookProgress = { book: Textbook; value: string; skipped: boolean };
+export type SessionRecord = {
+ lessonId: string;
+ progress: BookProgress[];
+ understanding: Understanding;
+ previousHomework: HomeworkStatus;
+ nextHomework: string;
+ observation: string;
+};
+export type CancellationInput = { lessonId: string; reason: CancellationReason };
+export type CancellationResult = {
+ reason: CancellationReason;
+ counted: boolean;
+ completedCount: number;
+ totalCount: number;
+ parentConsented: boolean;
+};
+export type SessionRecordResult = {
+ completedCount: number;
+ totalCount: number;
+ reportReady: boolean;
+};
+export type RecordShareTarget = 'parent-summary' | 'student-homework';
+export type SaveRecord = (
+ record: SessionRecord,
+ signal: AbortSignal,
+) => Promise;
+export type SaveCancellation = (
+ input: CancellationInput,
+ signal: AbortSignal,
+) => Promise;
diff --git a/src/features/session-record/useSubmission.ts b/src/features/session-record/useSubmission.ts
new file mode 100644
index 0000000..590f5c4
--- /dev/null
+++ b/src/features/session-record/useSubmission.ts
@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+/** 중복 요청, 화면 종료 후 상태 반영을 막는 저장 경계입니다. */
+export function useSubmission() {
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState(false);
+ const active = useRef(true);
+ const locked = useRef(false);
+ const controller = useRef(null);
+ useEffect(() => {
+ active.current = true;
+ return () => {
+ active.current = false;
+ controller.current?.abort();
+ };
+ }, []);
+ const run = useCallback(
+ async (save: (signal: AbortSignal) => Promise, success: (value: T) => void) => {
+ if (locked.current) return;
+ locked.current = true;
+ setPending(true);
+ setError(false);
+ const request = new AbortController();
+ controller.current = request;
+ try {
+ const value = await save(request.signal);
+ if (active.current && !request.signal.aborted) success(value);
+ } catch {
+ if (active.current && !request.signal.aborted) setError(true);
+ } finally {
+ locked.current = false;
+ if (active.current) setPending(false);
+ }
+ },
+ [],
+ );
+ return { pending, error, clearError: () => setError(false), run };
+}
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>;