From b039065d8142c73d944eb28b0dc96c6b74f9f3cb Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 00:33:43 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.json | 3 +- assets/images/favicon.png | Bin 0 -> 4148 bytes assets/images/favicon.svg | 37 ++ docs/profile-input.md | 20 ++ docs/screenshots/profile.png | Bin 0 -> 20690 bytes docs/screenshots/terms.png | Bin 0 -> 21418 bytes docs/terms-consent.md | 24 ++ public/favicon.svg | 38 +- src/app/dev/profile.tsx | 38 ++ src/app/dev/terms.tsx | 32 ++ src/app/index.tsx | 7 +- src/app/profile.tsx | 4 +- src/app/settings.tsx | 2 +- src/app/terms.tsx | 6 +- src/features/onboarding/OnboardingApp.tsx | 419 ++++------------------ src/features/profile/ProfileScreen.tsx | 230 ++++++++++++ src/features/profile/profile.ts | 8 + src/features/terms/TermsCheckbox.tsx | 55 +++ src/features/terms/TermsScreen.tsx | 275 ++++++++++++++ src/features/terms/terms.ts | 11 + 20 files changed, 848 insertions(+), 361 deletions(-) create mode 100644 assets/images/favicon.png create mode 100644 assets/images/favicon.svg create mode 100644 docs/profile-input.md create mode 100644 docs/screenshots/profile.png create mode 100644 docs/screenshots/terms.png create mode 100644 docs/terms-consent.md create mode 100644 src/app/dev/profile.tsx create mode 100644 src/app/dev/terms.tsx create mode 100644 src/features/profile/ProfileScreen.tsx create mode 100644 src/features/profile/profile.ts create mode 100644 src/features/terms/TermsCheckbox.tsx create mode 100644 src/features/terms/TermsScreen.tsx create mode 100644 src/features/terms/terms.ts 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 0000000000000000000000000000000000000000..96fcf372db25b29bc73feb6d80042548f8ef3743 GIT binary patch literal 4148 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bacf3b9e79dbd496dacd829e9fc1e0a29981a058 GIT binary patch literal 20690 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 Y0IT;tLEN4CL;wH)07*qoM6N<$g7xNnod5s; literal 0 HcmV?d00001 diff --git a/docs/screenshots/terms.png b/docs/screenshots/terms.png new file mode 100644 index 0000000000000000000000000000000000000000..dd5aedeaf64710e155b5b9edaaa93a7ffbafbc95 GIT binary patch literal 21418 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 qsUDb3{{P?daf2_A!3N4M%nT3rXT9Ia(s2tY!r \ 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>; From bff10bcc95921c8bffe618484f3c94dca3b90e22 Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 00:54:40 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=ED=9C=B4=EA=B0=95=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/images/session-logo.svg | 94 ++++++++++ assets/images/session-success.svg | 94 ++++++++++ .../session-record/CancellationComplete.tsx | 109 +++++++++++ src/features/session-record/styles.ts | 171 ++++++++++++++++++ src/features/session-record/types.ts | 55 ++++++ 5 files changed, 523 insertions(+) create mode 100644 assets/images/session-logo.svg create mode 100644 assets/images/session-success.svg create mode 100644 src/features/session-record/CancellationComplete.tsx create mode 100644 src/features/session-record/styles.ts create mode 100644 src/features/session-record/types.ts 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/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/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; From 117515d8e0511128a2dcaf15eaade276a157b651 Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 00:54:43 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=9C=B4=EA=B0=95=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=ED=8A=B8=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=83=81=ED=83=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session-record/CancellationSheet.tsx | 100 ++++++++++++++++++ src/features/session-record/Choices.tsx | 30 ++++++ src/features/session-record/useSubmission.ts | 37 +++++++ 3 files changed, 167 insertions(+) create mode 100644 src/features/session-record/CancellationSheet.tsx create mode 100644 src/features/session-record/Choices.tsx create mode 100644 src/features/session-record/useSubmission.ts 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/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 }; +} From f37a24a62d6b1d3574d7c3e06f433f802d7363cc Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 00:54:46 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=B0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EA=B8=B0=EB=A1=9D=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/images/record-success.svg | 65 +++ docs/session-record.md | 48 ++ src/app/dev/session-cancellation.tsx | 4 + src/app/dev/session-complete.tsx | 4 + src/app/dev/session-record-complete.tsx | 4 + src/app/dev/session-record.tsx | 4 + src/features/session-record/BookEditor.tsx | 114 +++++ .../session-record/RecordComplete.tsx | 169 +++++++ .../session-record/SessionRecordScreen.tsx | 436 ++++++++++++++++++ src/features/session-record/preview.tsx | 177 +++++++ 10 files changed, 1025 insertions(+) create mode 100644 assets/images/record-success.svg create mode 100644 docs/session-record.md create mode 100644 src/app/dev/session-cancellation.tsx create mode 100644 src/app/dev/session-complete.tsx create mode 100644 src/app/dev/session-record-complete.tsx create mode 100644 src/app/dev/session-record.tsx create mode 100644 src/features/session-record/BookEditor.tsx create mode 100644 src/features/session-record/RecordComplete.tsx create mode 100644 src/features/session-record/SessionRecordScreen.tsx create mode 100644 src/features/session-record/preview.tsx 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/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/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/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/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)} /> + + + + + ); +} From 9534dfe1061a5eec4b3c88c834061167fc5127ea Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 01:18:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EB=B3=B4=EA=B0=95=20=EC=A1=B0?= =?UTF-8?q?=EC=9C=A8=20=EB=B2=84=ED=8A=BC=20=EC=83=89=EC=83=81=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ActionButton.tsx | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index 0d68ad9..73c0456 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -22,8 +22,12 @@ export type ActionButtonProps = Omit & { /** L: 가로 꽉 참, M: 96, S: 72 */ size?: ActionButtonSize; variant?: ActionButtonVariant; + /** primary 버튼의 기본 파란색 농도. strong은 진한 파란색을 사용합니다. */ + tone?: 'default' | 'strong'; /** true면 글씨 대신 로딩 점을 보여 주고 누를 수 없게 합니다. */ loading?: boolean; + /** 필수 입력 안내 CTA처럼 비활성이어도 파란 배경·흰 글씨를 유지하는 경우에 사용합니다. */ + disabledAppearance?: 'muted' | 'brand'; }; const variants = { @@ -51,15 +55,22 @@ export function ActionButton({ label, size = 'L', variant = 'primary', + tone = 'default', disabled, loading = false, + disabledAppearance = 'muted', ...props }: ActionButtonProps) { const v = variants[variant]; const background = (pressed: boolean) => { - if (disabled) return v.disabledBackground; + if (disabled) + return disabledAppearance === 'brand' ? semantic.action.pressed : v.disabledBackground; if (loading) return v.loadingBackground; - return pressed ? v.pressedBackground : v.background; + return pressed + ? v.pressedBackground + : variant === 'primary' && tone === 'strong' + ? semantic.action.pressed + : v.background; }; return ( @@ -75,7 +86,19 @@ export function ActionButton({ {loading ? ( ) : ( - + {label} )} From 13c132c7be96c766dfc94e6cbac574cc96fc67f9 Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 01:18:03 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=ED=9C=B4=EA=B0=95=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EB=B3=B4=EA=B0=95=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=20=EC=A1=B0=EC=9C=A8=20=EC=8B=9C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/makeup-coordination.md | 42 +++ docs/screenshots/makeup-action.png | Bin 0 -> 20122 bytes docs/screenshots/makeup-one.png | Bin 0 -> 27945 bytes docs/screenshots/makeup-three.png | Bin 0 -> 35233 bytes src/app/dev/makeup-coordination.tsx | 2 + src/features/makeup/DateTimePicker.tsx | 124 +++++++++ src/features/makeup/MakeupSheet.tsx | 342 +++++++++++++++++++++++++ src/features/makeup/dateTime.ts | 39 +++ src/features/makeup/preview.tsx | 52 ++++ src/features/makeup/previewActions.ts | 30 +++ src/features/makeup/styles.ts | 128 +++++++++ src/features/makeup/types.ts | 21 ++ 12 files changed, 780 insertions(+) create mode 100644 docs/makeup-coordination.md create mode 100644 docs/screenshots/makeup-action.png create mode 100644 docs/screenshots/makeup-one.png create mode 100644 docs/screenshots/makeup-three.png create mode 100644 src/app/dev/makeup-coordination.tsx create mode 100644 src/features/makeup/DateTimePicker.tsx create mode 100644 src/features/makeup/MakeupSheet.tsx create mode 100644 src/features/makeup/dateTime.ts create mode 100644 src/features/makeup/preview.tsx create mode 100644 src/features/makeup/previewActions.ts create mode 100644 src/features/makeup/styles.ts create mode 100644 src/features/makeup/types.ts diff --git a/docs/makeup-coordination.md b/docs/makeup-coordination.md new file mode 100644 index 0000000..196fe8b --- /dev/null +++ b/docs/makeup-coordination.md @@ -0,0 +1,42 @@ +# T-12 휴강 등록·보강 조율 + +첨부 안내에 따라 임시 일정 홈은 구현하지 않았습니다. 독립 미리보기의 빈 배경 위에 바텀시트만 렌더링합니다. + +## 동작 + +- 휴강하고 보강 날짜 받기: 휴강 등록 콜백 성공 후 후보 선택 단계로 이동 +- 이미 휴강한 T-10 완료 화면의 보강 조율하기: 후보 단계로 바로 이동 +- 후보 1은 기본 항목으로 삭제 버튼이 없고, 후보 2·3에만 삭제 버튼 표시 +- 최대 3개, 추가·삭제 후 번호 및 CTA 개수 갱신 +- 날짜 필드 터치 후 달력 선택, 시간 선택(30분 단위), 유효한 날짜·시간 검사 및 중복 후보 차단 +- 날짜·시간이 빠지거나 수신 동의가 없으면 공유 비활성화 +- 저장·공유 처리 중에는 입력·추가·삭제·취소·배경 닫기 차단 +- 실패 시 후보와 입력 유지 및 재시도 +- 시간만 바꾸기는 날짜를 유지하는 별도 시간 변경 콜백 +- 달력/시간 선택을 취소하면 기존 후보 입력 유지 + +## API 연결 경계 + +`MakeupSheet`에 `MakeupLesson`과 `MakeupActions`를 전달합니다. API·카카오 SDK·실제 수신자 선택/전송은 미확정으로 연결하지 않았습니다. `previewActions.ts`만 지연·실패 예시를 생성합니다. + +카카오 공유 창 열림과 실제 전송 완료는 구분합니다. 공유 결과는 opened/cancelled/unavailable로 처리하며 미리보기는 실제 메시지를 보냈다고 표시하지 않습니다. 실제 서버가 휴강 등록을 완료한 후 후보 선택을 취소해도 이미 등록된 휴강을 자동으로 취소하지 않습니다. + +후보 날짜의 과거 허용 여부·수업 전 보강 가능 여부·시간 간격은 API/기획 확인이 필요합니다. 현재 날짜의 유효성과 중복만 검사하며 임의로 과거 날짜를 제한하지 않습니다. + +## 개발 경로 + +- `/dev/makeup-coordination`: 최초 바텀시트 +- `?scenario=loading`: 휴강 등록 처리 중 3초 대기 +- `?scenario=error`: 첫 처리 실패 후 재시도 성공 +- `?step=candidates`: 빈 후보 1개로 시작 +- `?step=candidates&count=1`: 유효한 후보 1개 +- `?step=candidates&count=2`: 후보 2개, 후보 2에만 삭제 버튼 +- `?step=candidates&count=3`: 최대 3개 +- `?step=candidates&count=1&scenario=error`: 첫 공유 실패 후 재시도 +- `?step=candidates&count=1&scenario=share-cancelled`: 공유 취소 안내 +- `?step=candidates&count=1&consent=no`: 수신 동의 없음 +- `/dev/session-complete`의 보강 조율하기에서도 연결됨 + +개발 전용 진입점은 production에서 홈으로 이동합니다. 실제 일정 화면의 이벤트 연결은 담당 화면에서 콜백을 연결해야 합니다. + +날짜 필드 안의 별도 달력 아이콘은 표시하지 않습니다. 날짜 필드 전체를 누르면 달력이 열리고, 시간 필드는 시간 선택이 열립니다. 일정 홈은 담당 화면에서 시트를 호출하는 방식으로 연결합니다. diff --git a/docs/screenshots/makeup-action.png b/docs/screenshots/makeup-action.png new file mode 100644 index 0000000000000000000000000000000000000000..dca89982bc1e12c937189ec19cebefb2db910b7b GIT binary patch literal 20122 zcmeFZXHe7K`#z|m@`wT|AkrU^B3*@0g{X9p-lPQ)0RbV>AwcpdB99=TRH=~;p@iN; zlp+Gs0|+F9UJ@WuLVzS>^V{9uzS`N@{r_k7#qM_|FEW{vIp=eqyIkkG&-M9WZmQ4D z#?N-<%o%n=10BmVXU@JqbA|==*E!}dm%iDN&z!k>#!%;xby&_8jy1=6l1|yJkII;j zApQNg66+adF(NuKEtFi1t_{hQWj)i^^`7n}f-DCF z>i6n6|DAM3&@t}s3!nER3|FrG<2KA$pu>8`2 z(M$i_(z$*{`(Lp$@BcI4KX&;4mp^#2{wD_hlMDWn6aSO?|4&Mw*i7$8oU`AoMDzAP zg9CwQ5+l3}tG(ZARF(KRjrAI1A%=sYt=9%|C#aQcuM5oG>{9Hy?e>#0T6D)75;(qn zkV+f^`mr<%W&Zc~&pl!6s;<4Y$BN4r{|77Y^%2gtUQP>iheoSIEx^SFYueHB=A8p#=|@H5_<8ClZw=69JjZ-Rfpcw z$4_$X@YGP6dPar_)Q0lS#swK1egSw;n`O86y!eIBVT+J`%;1Mh)i5i&W~bDl1F<_+ zCEz%yBw(=RvOw!zIY?}KgnZ8Z7op&l_toEqx}IL5<+k=<<6Fsza-y3zje$pOviMcD zIFXI%9l17T#c3+#APcmAIVIQSCRg!T6M}+l0$#mlD0!hetFC{0=rlj6c){3My+@fX zYr*-#{>uqyGZa1tUrxT>v18fIR{f+-qrGV^Wt+^`ediwXl|_(+V^jGOqPV?Zz!@%K zA-p@Bf@)_k)E8{SeCJ@UpdID=t3rM1j)U+6yib|;kF<$6+a|Z+&cLs4$N_N4~xMU3{$U^p&7K5Abdq*wxRM=x!1}U?Ey=$3k)`UeO=yxRrPR z;G+Y`)W%SO_Nlw(sWndNR{n2Ysec7Yi(f>O6KktjodTrRZqodxXui5WO255Cepd3d z$NQEKn=Z4>F?L1vlXG=j)GcbXAb4SuOPcH||N1r}X3GYnd_#Ki}q0L?io?AW&#honOGv&{ll zEwRJ0tXBUPxoze4&ZE_9;5KuU@co9-0=_bE&Mk5jf-af&2ld}?Ju;Bi z?59eFV~chNU4uCik7~KEYVvc-`zW$+{HXp=H&e5^Q@2+A9N}X(M7>n_+PYaLdEklR z8`8DQTInJ?ULSS}^xoj&={vTe`>`f8$l)gi1tr=Jk#Tcp2;1R-|^S9PFoCId7o7A~L%W za@=6D^UDDwZX9|vN%->+o3RXXS&%M*1pDIBzy)Db-GPDE*titjUH zWPM>*3PaFuFesPUlI)c#2}#wR6MDjQOO%xl#Mh9W3JZ+Sf=^+?lI=c^tpwdo#$0~s zGL&oIUh_mF!Y6g7`%TBu6+P%rH?KoNDu1=tvvU^cv7aQ=F(4tI+mP*=iR>_T%j4Cq zULi{s&ziHWrU17br~Oil_2|are3Wzxpl4rlko(?%#!$QO8 zfH$JX{U*DA3Com4(7XCI#tt$G`t2;W#Txd_)4}D-bWpB$yP|iy-CD$l8?psdG_pWV z61h=u;y%NQ{v;_`prIVusxoYI=yg=$UoH9r80~7aU^w@AvkIRg1eE~`W-a*g*_-s; z(-#wpAe{GFDR4;sAq&t3eL|`EP2E6hFIrWWiIWCH@V-HVoELSTf4$75-f$`Jby4R( zKa2wWPz45-^Wwre!Iu8|@v~U)zS=DnAhwUKIHWpn5TWU}bkdcnzzzi55on(h5cgVo zvQJ;fkiEyV&{n1i?A%cp-uBMM<`F9uNHn0eByGBHO!01L1%=jA;Fl3HS&CPB;s*^o zIs_9Yw{)VKqx3!Nt^?114w5ZbOVdylU*QE{wg!vBjc62(R@`y4xr<;=5dsQh7%?x5uEbf?FVzBuf;YTEwx zb#XR-m2jfLfAM0)0zn4V-tzk*Tj9cA#_nH7jXpu6=#ib$YsYYeO|QD#1b&f?b)f4 z&2>GV$siR2AackaqE)Q8>Xx^KTE`R-o>Ns$sX??zB0 z0l6ueIxqs3-@DI!M+U>s|#I+%JkT~}U+bl|1?`-!yLpt)8@XDMGbbK27B7fE^B zC&97Y6}DC0Z3w#yi4RV^{!WHISF#`<8zChNP7U^rdaErhXv9W8-;apxYu09o3$>VH z{^ltmK60A((+TA6e{%upAE0SFA+{gh6nMSim@ITD=D?&TZ~%F9k}s)$9GSDNw0_`L zuVTYZo_L$Sa{#p#EVrMsF(ON(hj_LoP~&s;ZYltW-J&ZXLs#7J@PrpR|m8i{{FVUp*EXD*4AP*Rnu~-r!Lx(5>NY3 zN1t~eHUH1>082Lv#bK8;|5<)2m<_{PdD^ zPR?jWTw1e?4dOQv4Bq?eThdpH!uM=~y`~!j%%bF%i?7#T9Y_;AL%YMR8khU=CqIJHi!vEdrWtS5uOc z1N(H2_4@%bf9x91JTVs(-@?J+>B$SPMO_yzX4eIkJeVv4deRXQB+c9wW@aWuK%RWB7VCNEL)62$V z6qsVBMSrzD+qgXk$8P>oG_H^G^V^w^qBfZ4M;`a|+Zr_}{r zm^NX{5eIi1Pl*Ue1(D{Xz5{{@80cDg>9A&V1+3O0rKBz74+FF{jYP()G!k69-zN#F zglyKiS2;8h#*6F6OPO2#o!xY2)SrIBuAl|n7&m53J@pIS!5;UZ^R74tP9&(Dzug#- zw7>o67+G0)y{*k)0v2R!=5f`+ZQ+Oct=snuX0sXQCt20=lB z-F%z8$pq#IIj4pe+o3Ga+{w+X(ec+>!Av;d8?CU*_dDC=wq^*C~Mo}qewPQJm z6Vzm0moP1Haq+G9B#v-WGba%H6&k(kk`x?U7dTywXPl0WxkNPv6Kyqnf~P@&coap@ zc09(R=GmjguC5EUqMMU;F4R>djH^xWSoQglelT&$Uv<7$Eg3TywmNAC+n>W9^NP^v zw<&m-%b(dK;PF98Huw_mbQgU}-Zul<)Ke5pzby2=;pkQ1g!Y3CGMpj}=Thc;T!(Yi zG$BF26^^anwac?auz6fe^#f9eA0uKn#)wBIy(5JI*~3GHarF9jh)X~}HarjxIpGV| z^lRHpzvq$=P^^5kYS(!R>A2z7u?T^g4DkaxkG3X8ePR`4{>!!#cka&f*vg(QbW-@sO%{8_K-hO*8f1 ze)AoMRgc+EJwx$|2hkK-v%}yGIj3j;d=1(NhK_9IFi9|4w5%UXwNl2OfOAF|4|LFxyDPzFs!rxAC3t zDAr3LRC5P0l4z%#4lO4LnMuyJ)As^EDlPu0K&Te=6%5(+;57pkC$@deWkw_JqG@VV zLz1R6QwB!|wj|gG)>9PKzPgre z>OJv8NTPS9s6U179=tRQ$AUIRnug{`9ThN}O#XY3UtC@hX306HOjo9Q&Ub|SOjS>0 z@hRR!3NbTV4bIn(DZMW?RJ(0Nk>~6 z?NGUx7oa@l*61@m9DZTVj9b$cJ;>_<-9)~MuuNSr$Llks_b|TxwVO875i2vF#YOz- z6pSC)u@sO{-uP#r0@>PK>#^-16p=?K1v)_2HW^qL9_0{rkOXUEyoqfP$afrO-wGMY z!$tRkZF*?@=^%VIxYY)zz%IP?Iw#IkK}vBVd&gq8z^OF=dP&j1#G#mvcZ1i-zt!%J z#idP35^V=}3naOb8yfrIV1dQKz{m)4QVzq1uW!5k_~?v+B(#|_lbgA2iFX=8-M|l5 zXQQYgzV+>B7k_k_Q|mRF@qVgoeLx0zG~ou)*RBsknD@>a^5`pdS~;M|Q*-Mf+ga)9 zCGY98L?m@u*=GuqHvIFm2=#K-qX{&OF~%{TklD>mK*(qJXv95M=|kjH%gNh z$Bz7SmQLg7Eiz1rE~eiF!Zyz8CPA{KKUFNxwAl>qtqziY*IJoOgVzZg&XCRG&nlQe zqQadLpXq7A|_DrirQRXB1wwt|fH8lD_aV}*pXi&dK z_%gf7Mnbjyc6!yEQ_y0Jv_TbHP@-SUsXBX%lhmuZrlFB*F$Oh+e6PxQ`BpJip|uGG z@^L9VCuFv~b_bzNOrhd~qZNzE|pi)qQ z^vlz?T^Etz>bm0s;0~Tfe64FY1eR~QJ^MY4oj)w?)TSaXSU}Pw>wv^~P#kkgKY4HZ z@+HS&Uc_a6;(E|#LG&lQ+GOo|60^>DjtIkO6L1?2E0?E0D@jfyGH52s%%n~a;l10lP3#Fp ziX#uSCq*x`l}OCO1?RhMGQu&u@bbG#9$Qa?9q0le^{?p1gcx2%DWVDbR>0>+p~nzE zM_;>$*Xi@!rLGX0cH62^axn3RDT{(*Cv7zYG%(z6n{B;wg}PCQ*PPSzieeN zg3C=ALTu`K?|=lM76x?Vd0W3#!d>3kb`$YPd|-5*of9IJtMbbRg^fOmX}36Ev09OR z_4pwp%5Rh+W7}dmVU8iWs3M4LLasUCbZ@_GS;Iv2H+!Ygl zN;_KKd41&+AAg#6=R29pr@neUM{y;$d18D*Fi`_&Db}bJ_Q$E&&v|2|7Fa4{qD|jZI;(4JFx*Y&AYz&1*vmR%-Ky{;2)8!mf|GXY3gtEw!WgdAGPGvz-<;t`J z#gX(56DCPhVETqNu>RL~t9Lne@1U=jf0!WB%2=*%MZcUUi~(s{2TgHk})KWdo1Le2zq8 z%FOYnJs-G8EksX(uDrwDk;S9E+q#zfH)3GdnqJN9jdHCpq0Wi|rou5ulyrgDc?+Js ze$KN_2c<3kHsBV)#+xy48$DI=!1CTtAr79GW*kqd^@B-t&Ua}Rk8P^0pW5OZeL`Os zOOWzTMpBYA5Bp%J`p9yT{W$Ou1r1#8teArM%dA5CPK$^pwy?TVf z>0fs=HM{W=K@XLDXAcIE9pj9`s~0|9uhEUaf$VtjG?0MUa0wd|C!tlu21pSUTwD>e zxTQz@S%bs&mSY1#@spJ!q}rc${8_ZzexeJQXU@7(A^`rw`4ho?1h8n#84X>(fEor*%dd%iWrE0G zy(PA@&5Sd^B6wOwz6;A-H=f2`h@B*d+A?b5$MzXG=zv`X{t4uLf>~`?Co#hLgzV7 z=~*5Rc+3dmt81%ia*iQ(U(NW9Qlc2NK0K66#Gj-*mp6aMueH@(rP(JLuxo@VmyfmTcz)&CL5l03c+1L~r+B7}n6Nuqq` zcpDjcJ2=F!@uc3Ltgx%FEJmFA&K7O5vYM&>#q)r-)~Zw+wxN&ijC#y?s6r4~tyt1J zDrV@S!Y`HTA3I!Vaa%M?(&9hPzbpb(ebOivyF6y`q3D^29*}DM;Lj~MF+)Y<9S=s|Ls6}{QcK=j`u^msuS8$9`S#z0)`bl6C0}3rN2&2O--H8 zaN67XL{a*V^tVrww$Y-86={g#O{8g#HK28hZ1|wce5955@zZHp z)uC*C8){3iwWuMTR+U|tV^VmlyM_}us`Goc(Pz^CFV_cFXtfJ@6B8iC;N~jT6Ga(3I>l_12RsVWh?>$ zl4Tm}RtHXCI=1%?B@5nd;*l=Q;~4WO)nMu8QsFVAL27Y!h5XC_j|Pso^8(}Lp3K~j zm9Jfv?8BBC39~BUpo_g3E#E%C^#6=yWR((wzw1i3zD6^ z=k3hbQ;1;68{C3R$n8$L-+1D;jG2sQOzf3XY7**ilhwyg9_lgFD2#gks%$23{Q^3- z_kvcXGBE4uNPjghLfLnIi}AAl_S}fb%Yg$v$LBD`w}*eez4=A5tIleE_Bp4nPzE=~-Np%gA)ph-kUYDxvX-cJr1{bk<&d_Z!tLSio2ZW7UI>eh%jjSK817tRzkIvOedn;Sc9X5~Rz=O)aS_D?96X zT!$_Bps?e{Vb9!|rr;yVZ>Bj9iuK6eY0|d8hKA-Kf3k7o6PAkgY!j`IkN5DAD&_9` zYh)r1v~$)6%xTf0vlE#4@gsGoO*4T01=*+I3_7xpr(mHKj76F&^>`o~JY8wAyaeji zN)6~L>aSn>)xMtSNRN;ESO#y-{rq`M`cLuwSw`qQ<4>Za-;?;G6YMTm2ly^hc)7kC z9}at2_@2G4uCB>%ZobLn$f~IOM+;a&2epV4=@?ksJu_WOkFgN7!Ja+CcM6H!XO5_NN~F`_w-wyhDeamTXUviH!}j1A{|#6<0IsM;(; zcwO%nd{j9+i^5k4Npc=7j0Jtzo@-`BI@J52Kx-UhonP-TiP)pF&QfEY(B;8 zj_1m5H!(3Xs*QP--|eW&z{|7>o?Qp z+h6A%*TK(zh%@IXRJ3lU^;ss8xgwJmEw- zYX)2yzrVnHB)lPZ9t|tyN-DQ`H-N#3jUsveB<+KiaG3IS05Z#9rO~ zSai^!1WZ1FL};#`vnzR}*Siu| zj9S|oZ5P@ntDH3B#d%{iow9A^6t-rZFB23twhLG~`G4%7BAj0B^U7Zl@F^RUPNuDD z@jBmhVjO(sb&QRSSe=iG;JL#iV}0j%85R8=wwDQsY{rM-kispLDvFkYu`#N{155u2XXvDnS_Ea7H|?%57y$278$UDkeOlZ>iJy1Z*EFF5&W1gUK^Kg`$D^};?eaiNn! z*|g!ftx-UJp&Y6tyZIIB5HU!cEHAVcqMbO-7N^YVF{G<5LqhBnB*N|~sF1cPv2DK( zI#7TW7BV$x#H|k&0h*S#P{~${sBY?@HaQ-2PWGC0-YnJ%ek#V?1b~5^u=8wlXboK4 zXBs)}r{sqzGpR`KlqBnLjTl(MA6NMu^}#*dJ&7jeJE1!zPC{1{S{G=xtZ0Yv(!!lN z66|To6TR(E3b?6<+W8?vX&D!i7_qwgcj+(Blis@aLtdqjl>g%HMoXRA+dLIs9%O@d**oJr1mSgo4;qSPLmHEt3UQ2oeyEk?qKk8upL0tw8#D*86hVDnur2 znjfkP?b2yfqIAE^cnowjl~Y!`8pxzh%C?{#{p&92d-Xd94!@jh9^MWjNBMQ0@{`N;BTtSi)dEL$g7h}N3}y(0 z36zS^vcTlPz<_|Rotji?tM`zDGf$R&ful{OIdqjUsbg8DI-RT~LQebP6pDw&AU_N$)_`ZWHuhjx~PrO}xYYb)R%PxLH z@+oM@z|8J!Z+P_33MRU>(6SkS&%WL-`qaktvGXO~qiU7Oq+yLxW1My`es;(aQ&A9vcT^(}O$Etu^>qk2qdUc&S|#14gfN-4>av#WN3FWK`I&HGJC z+k}wjt*X4!76$kM2p=pR>Dhs-9k{F(w*hEdJ@KK}X63Q+$WSTrTPFI#54a!l>k|Q% zsacf_nKKNcdw#GRpLCtWyVFm~Kq+~7h|uuN`$(+j z_y6Vs+~1-T=UcwJYvw#R)x5pknpLnb>QyLWxXH%{@kma-4v!2mzm#fe@F&`2N}-EnkZSnw zOs?gGWpnJSLVmdD4~1>7Pdg$kS|>+bD4VVw+N#PthdeQ+WplP7nwN)F#aW}qV|T3f zv5Ddt#2Fpa@$#+T^^SI$41!+kH{M0(d9>MY<>DDLi+q&fvL2g`CfGzkWh%b&)p1-x zwu&9n7GOI~ddIVFK$s`zH~_p63iTnlwvLX-G-rT0rK0tI=yxWYaKq=~b}GlV@rIWI zs7;6cVYepl(c}0>3aCmb62G}U-&lcnRHI|8Lbhacq%COxKaBaU0uiBa#{U+LxuK`b zeh(mQIu!gYA>MnS#wsR=u(b*;{u*ml@w>s@amD7+hZr<&8gDxKtSDgBhy=a+#MjIM z5KcIlP=~m@nJcpB-XGDhBRLGQr&%YP`~_T~Y)p~2DOzDC;Us}#4D!MHR(%)5vdxU~ zkCWt&YhgHu38&5U%ZjM>6s36#zb2U9zbw9VwA`-QuO_yP#+J_LDnpF_{bzOI1eGpJ z1=Slvur?LbL24Bf_MSOSFJ=OGqn|%!Df?B~b-GmnMpe||GD~xpTrdh1l=pG;EbB>Z zCWBpu;MD}4Zn97LAwTMSfPa8e==i!GZvUO{!(DZQcc?@VcY0XB`}s0gw+btbkkKUd znsTY~e7ssUg^AebElRytrDL&X@RPKCUb#HAh(xxmwx^v=$MM6YN5Z;sj$-9|&CkY` zYSQ=%hw?OBfWy@4q7&~oS?{NZm}$^_e+OzCR-=?ie=RpYxz|A1@ik&ewBFzMb^zvWdH~`L6|~b5;srZ_FDm>vty}k71JHA2NxA$M#Gd}xfjRrP@MyIhjH!9=q6Gg+MSo^z!Z2-9>{yk z8v@9q*>_~S63;d}!4ja2&uVQoO?1b@*Mm4hytFkb=5BSMd%+h=9+&&Z;_{WR!An4O`*_%&He0h(c5R+QsU(09`y}EPuhcOam zFL9fmQ?D-dA;=&1o}HhV075@f+07pEDhHWZZo`(;k}i zK1FB_Ug=h-=SYumsr+Pv3MYKbbag}&7TtUHd%!+$y?jOowQhzM6ykH`e}}kV;8CKr z#zgbTmn!F~45CuX{H^$BRZ!~cSKd`)R+@z^6i%k9<{&P=jW1`NH-|}1g+0K`c;*SK z=Wot6`DS|z(A|aJDZL!m1tQvVf=f88KT`^ork~0Npbq5%+}D>}+^boVKti~B4Q|v# zlSpJ*^M&E(_RUa~h+#3-EER+m?H5t;ave)9=D$TnHaNVpwe{Gjs8$O@B?W)t6wc*- z(&#f~{oP>EM*Poev$q8W>t9h~pz`W>(NM#qa!Qu~`=;cp)!ATT@KX*DrUyN1_7d0y zFA9CbR^|axI$(BB*(*d&DKH=^vKLXd%}f}4i0@P``9}jfFwm)Mx!oqkEl87J;zOGL7(ROh7;Yo+JDsEwj&61ePwGOrler1Z5wT z$LD7N4uomTu_yYcA2Rqm0pQuAx57AQsV7H%b$Yw`+PC0S=h2s~%yRQ0kvHrMAs96y z-kW!%qO5H>i7eh!42GMRlVC*@s-W7H>N)3scyo-En((|B&&dq@;KCd74om0E#P0fKyz}0Rjk`9S(2%*Iot{HCSW@=I%LH)6`9faH*p_p4HGcFT zeDxkgXPe@q4F8JUSOB?9FM%)P>b=(!O6f594`%x<`ERcC$@8&FLel;rbZu0X2wJ<_ zaXDVT0V%XVt6xn+IA{H2QfVQOQYca0Q)uadv(0C^qH=c`R=6?xkfnSyKmBT(#Qg!A zhnM78O~+(yh1oPvr-MOZ6 z!=(P@p53ahNd>#WaX$gzGwIA9)?2Lo9(u6DDgv5qPI9T14hK$x_uzyY$p>Y6Uza)q zDZ(Z%^*CzHly;NEP}5wTH$qbYjph#&f_9q(kRlqV*@jjn$M}x2<~jsI94;xueeyg_ z#?H3>Cbe+rB)0K}_}hV})!fx>LEomiB>oFR>MP?uBBb^G@3Jk#*x%9??)`^42A9|o-;XdYJE2K8ueL1?cQ&L=9W>!hz zT5IP0=MUk@qI;j@Tv(&M6g_2z#yvxZs$IDr0+aB>l^^D|KMo8$o%4`2?&2O?#^v{?+@qcyd zFVGT&p7=j*R35G{$Caom!#uG6qZ-bo<44VZS*ZjCYn$V`XfNwbUfz102&BlfYhep(pr!*HB#+&kFGp}JiqD9>uN0Mj>pToKy z>+?<{;v%5c)F9pRCI&3mc@D8tZogA8$+4>|CZyXq7y~<5H?--#z1fge4Nke!9QC|y zE_d6sd=ZdFTY~G-n17)Xba`2a{BNTvHwbAIWxr%zsNJP%v0AP57JKnfZJ%Z@G?ldV zry|QbsJSuNhtjgX#$A29T3-Fw`^cldsf9Bm{PH#;oMHPU{<H9C{7UbMQ&*{Ui{1r)3Kzb8B4vAZm+{Gg!fiEp!#f#WSr)+w*xL? z)8w2OB^Em?{e1E_01t}IK0O{vDk{?wSr9k4NcVFwO*OFWahk^p z{gjI8>&Pih@o- zR?U1=t@OZ2Y-dPq$M_(TV`ZiF`b$Bj<=H=J<=0X~8J9LSazGQf*(t}Hbw$6G6%PJ9 zUqy-UOkUWjxucE*Z|iyv+Trk}t^F)0(Ok%9l57ZPNKj}qN)0mkEOnL_Jd7B*QfSplOR|53%5sh zAxa~|iT3}P8~ZtZcRVuN9eX4CFinV86(ZU9<#tup4u>6Nq1SFaP4@nIE;qYUjAwXL zd-GSkQsr4{bS1~6bAQk}VzVsnp#OW_@Zl56E*oiP{}^`e^Bci)pH?dV5`U0PWtT%EscfCn2D3nd&Sk2=hJ@RF8-o@Zhe05;6 z&XYJ?a2@qnYQ9T_*rBj_rSW-2`?3r&iPwHrw@mx;FvmYIwQG z(c=S3Y&){Axy$a*hj1Ehnwr3KRa<^n^y%&LIsI6dh`yK2&qHf1yv?%_euRe^$txH9 zYA&Y--90*R1ZuUubOZjQl$BEQrfB%isr*#x*AwR#VyMnh zO+dH1?u!GV?G6~PZzwfTC{bB-v#g~u-1WjbmV)~crAvfw5g;o5xdiUHqqpyOJ}a^f zni1@Els$No?Y8BeY^7EjFUQYr^T>6aeMVXYu@02pXkU>4KdQHO4P2k7-i7E*M-tY~ zCmuqpLW6bMHP0~xW>k%qLB}xEB{HiB|9ZM^Wc=_fN*AqrGvT9jQR~}tq$cSCuPn2P za0GjB<+aXYNIcZO4<1-46gED<3*RD6j#4e|BOG|wK2*n;Svxc&rsb{w1kb9rH`(C= z-(;H5{`$A5@Nru1+izP`UY3rKGUXCk^LF2xH#6iko=T73-CjPOSJRNc8%zIj4ryz7 zf9Ska62=b>UnX;3Y}%8k5(duEPqGVq4_?b(J?lD96#pUL@5~tvk^l950o!V-zsmW) zfE^l8MX^?LUi0jWGAUeLp;AZm!GS&v7F53F_}|U%yfY-$(qy?*xT`FtsMheQkD%g% zF1m&HFEv%fcnb0%tuD;nWcBsXrLfX#f{}wV*#lGDum&4TxDU=~ka6?Z57W1udr zG=l-ZqvAGqQnThW0VliL!y$#Xt@;u-K-LpB;*PNpn@9k44Gt?htiJUmTt zYT9IOG z7U7kLHDK9YgrSnTu`Sm-&=^3~My@cAQSiMX<&8_z;Ha5C4j_l9)G$VMPB_Pp>K9E( zxvDCqw;f3R?zWe^&Bn{r$z1#>>(tpCt)g26eS`mn`Jxzzs%KYVv}_!qRoZLzCp#=% z#cVhaf;j985{u?zyNthItI#j(?B^qKJ6l)jC+xdSpxzqa++Hz6MqHLQ_PJq}<`sO2 zUOAe$*?dZR1^z2jgc=mhaHJ~7rOu9qySX;Cwl)T70L?ez zR^`M&W8>L}x)prcmovzYWt7str$8=@aKrT%e|P>{q}q<`&X-Jk%Pj-NULjhA&Hppd z?{gR-#5QS*iBPX-ia<9QkYPVFjAf>QB} z=?UV`GIsFndZX*4488Emi+7ISy)WwNdPJPh`4X{o4%PHXvt#164rkEFmjdl` z|CQZyG&KjapM|_}{hU=mi_lx_*h`b^P^4RLLjx@qf1H*Z#@%H0?m18Hyy(e4-1i3} ztq%sR;@SA@BA4DfZJAgctBeV8+HbL+h~_$u$f(8cQC(n~8b;ZepZCJpTD)y$C-0u} zu?*za3=bCw&3`h11|u@`iImp(Sf1=m%iYt=eW&F(&}~XY$Y;F+xy_z)_=BMx3KuFb zcTEQ1L>`<=dh*9|v(|8a&&~&{h;#DF*|RmAUSkDa%WU|bEiN0bcimrKtT&VYBh=~Z zedaZe%dGJ_h@(5n+}f^}t_yy7GV}BmDhpoH&26~Z-)rk#S$eVZM)hiDHcFWDcpQ8v@Kq1?*`3FA?;1K#{iG`>9hKPEydF7=fagd zOb)Ehdk9yCph9aDTgd+qfn5TQ7ku=f(!DWxlc%>)8ValU_FGJka3c{LL1%4Hh_MW& zOtk~Zhl4LC%yO%rZpM7})#jrNwP|Cw2lwNKl^+z1wo00qq8iH@RGePdz`Jw1pnc(`rqVFhT^OXJ=}e3BFzSEg}MK` z6l#tY*Y+)AzC(4I+OwfK!|}}@8Hu3u4h|IVkexev6qC;^us;94(mxs0#lzpeeM_U! z&h>pT{q{lhySSKygoKz_i|PkQ1;g;v47k4VSxIg{IC7D7y5_GlDR5D zR= z_RE*#j{9S}HZL2hBshh6w_5HNhw!MV2Ly*O)SbwE03 zJoX8BFKHTc@NQ=)x#8~fM~QnIZ_^Dul@Agv<5kv?v)5L&TiXD}MkWFEQ>($P)> zNIN6{w$bBL0s^!P>KrF}Y6tgR6y_(w568P=niyC#Q1XEcwaM_*jG+{}eC~5lLCf;5 z)cq`p3kpzGmC)}cwDd`1{#Ll9$}WN9#cagfitH3MH zD9EO?%pJDwjF8MYX8L|%-)d&+er`}1MaAKp-4=e|PEKT1doyol3o@K@n#up|qZt-A z&8~(Fx4fjH*6cM-Ul$}BAY5AS2JD@SDjwjw1Q-YhI-F{aIr8uu4hqW)K<4rk1kIEO~izN zOl@YzHuds-78dxifHA=3uy?7WUF%QV1G57|nQVtGX>>_Srqw6r47H!G%K>JJd+uns zFibrzH48mZD}AolRUUk~Vsluakmn_L3ANI$e1jvyZ&$Qj_oM0P=uGSEBEtQ8o07cl z+a<}j1}FUy!QR#AudTxy88h7l=lir~R)AB)3|8u9 z@9WF|Ob7;N*3uZZ5>Y^<1=G# zHGumY?Sk|JO{wsoWZg?ggV!?K@1x|$M}|E*87+^+=nXi4lF!`fxyD~+e#WGOgEz2` zLp$@bo7j>?7QW!8-12d9fCZ|0>3NxfTI_^)v3~rW!hFK&(!{OJDOtk{TTwH959j`b zE_lWVq*awp8dtiM|2EB=D0&IA_JMgBRqKVjU_V<(MQe@w#~7THOW$GMpMOBaWrA2= zY$Wh)6U9zTA_(}t7Zl;hg|v!s1+dFjny)3me}IM01R*ixz|``1KB5oAw(KhdxX6Y5 zvL++&nZL_vi|~W`=Mmo%SwP}1x2wPChT7E4cgja@gc8sch2N=+_Plc5#<;yT%+G`T zYxZE);bu(DZjl3bQ<93#xLF#ZPn*S4no$AHauGYVLSjhGt7xDGW$jj`rsKPjw~ZCK z4Q{YUBj3V5vYy&jD{=bI_^>Leo*d5KFP)N^+DkF|%;L;w$M?<=Cjx|z?ifei38sMB z?mPFRx5X>g4Jc}ah{-%(9#;FZbkZUd?U*@ZAL*`~b#A@V(2R{4Jx%Kvan(^f3 zk!~|aL%q02LLc^5@KnHFSA?D%qAy=V5oXo7R~EQjq{3PFCh3=qHOD9X)qz}5 z*2Uj(`x&v*2^TxVzeYNwo-umzdU;3Q>hR^N8F$u}WKv>8$2ZAO3&$FYQ)z^vfdRe` z)Vh6@E&1OI1sP@aftLqL?*A`qaIyhzKVQoorMgsY`BvVa$=tw7HuAMP58Sp{MD(qtm;>7Cfy2tvc6~j9N;k4rGJSbCyPE80jrJ*db6YrqHWt6_6A}`dBs5v*g*KzQ5UBc72UUM> zH*aTr(4M@7mCR#R2JMk(|LlPW%l-eq$Lm!BkiiD(Dl#)nxpA)d;HnxApa_GftDnm{ Hr-UW|IYRXM literal 0 HcmV?d00001 diff --git a/docs/screenshots/makeup-one.png b/docs/screenshots/makeup-one.png new file mode 100644 index 0000000000000000000000000000000000000000..56bf85180db9c66901053c8a8cbeee50b5998ac4 GIT binary patch literal 27945 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 TC!iPj$vtUt1+mgkhTs1amLGH> literal 0 HcmV?d00001 diff --git a/src/app/dev/makeup-coordination.tsx b/src/app/dev/makeup-coordination.tsx new file mode 100644 index 0000000..942a4d6 --- /dev/null +++ b/src/app/dev/makeup-coordination.tsx @@ -0,0 +1,2 @@ +import { MakeupPreview } from '@/features/makeup/preview'; +export default MakeupPreview; diff --git a/src/features/makeup/DateTimePicker.tsx b/src/features/makeup/DateTimePicker.tsx new file mode 100644 index 0000000..6c8994c --- /dev/null +++ b/src/features/makeup/DateTimePicker.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { Pressable, ScrollView, Text, View } from 'react-native'; +import { ActionButton } from '@/components/ActionButton'; +import { normalizeDate, timeLabel, times } from './dateTime'; +import { s } from './styles'; +export function CalendarPicker({ + value, + fallback, + onChange, + onCancel, +}: { + value: string; + fallback: string; + onChange: (date: string) => void; + onCancel: () => void; +}) { + const initial = (normalizeDate(value) ?? normalizeDate(fallback) ?? '2026-01-01') + .split('-') + .map(Number); + const [month, setMonth] = useState({ year: initial[0], month: initial[1] }); + const start = new Date(Date.UTC(month.year, month.month - 1, 1)).getUTCDay(); + const count = new Date(Date.UTC(month.year, month.month, 0)).getUTCDate(); + function move(delta: number) { + const d = new Date(Date.UTC(month.year, month.month - 1 + delta, 1)); + if (d.getUTCFullYear() < 1900 || d.getUTCFullYear() > 2100) return; + setMonth({ year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 }); + } + return ( + <> + + 보강 날짜 선택 + + + move(-1)} + style={s.monthButton} + > + ‹ + + + {month.year}년 {month.month}월 + + move(1)} + style={s.monthButton} + > + › + + + + {['일', '월', '화', '수', '목', '금', '토'].map((day) => ( + + {day} + + ))} + + {Array.from({ length: Math.ceil((start + count) / 7) }, (_, row) => ( + + {Array.from({ length: 7 }, (_, col) => { + const day = row * 7 + col - start + 1; + const date = `${month.year}-${String(month.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return day < 1 || day > count ? ( + + ) : ( + onChange(date)} + style={[s.day, normalizeDate(value) === date && s.selectedDay]} + > + + {day} + + + ); + })} + + ))} + + + + + ); +} +export function TimePicker({ + value, + onChange, + onCancel, +}: { + value: string; + onChange: (time: string) => void; + onCancel: () => void; +}) { + return ( + <> + + 시간 선택 + + + {times.map((time) => ( + onChange(time)} + style={[s.timeOption, value === time && s.selectedTime]} + > + {timeLabel(time)} + + ))} + + + + + + ); +} diff --git a/src/features/makeup/MakeupSheet.tsx b/src/features/makeup/MakeupSheet.tsx new file mode 100644 index 0000000..565c057 --- /dev/null +++ b/src/features/makeup/MakeupSheet.tsx @@ -0,0 +1,342 @@ +import { useRef, useState } from 'react'; +import { Keyboard, Modal, Pressable, ScrollView, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ActionButton } from '@/components/ActionButton'; +import { useSubmission } from '@/features/session-record/useSubmission'; +import { CalendarPicker, TimePicker } from './DateTimePicker'; +import { candidateError, normalizeDate, timeLabel, validTime } from './dateTime'; +import { s } from './styles'; +import type { MakeupActions, MakeupCandidate, MakeupLesson } from './types'; +type Step = 'action' | 'candidates' | 'reschedule'; +type Picker = { kind: 'date' | 'time'; id: string } | { kind: 'reschedule-time' }; +type Props = { + lesson: MakeupLesson; + actions: MakeupActions; + onClose: () => void; + initialStep?: Step; + initialCandidates?: MakeupCandidate[]; +}; +export function MakeupSheet({ + lesson, + actions, + onClose, + initialStep = 'action', + initialCandidates, +}: Props) { + const [step, setStep] = useState(initialStep); + const [candidates, setCandidates] = useState(() => + initialCandidates?.slice(0, 3).length + ? initialCandidates.slice(0, 3) + : [{ id: 'candidate-1', date: '', time: '' }], + ); + const [picker, setPicker] = useState(null); + const [newTime, setNewTime] = useState(lesson.time); + const [notice, setNotice] = useState(null); + const [registered, setRegistered] = useState(Boolean(lesson.alreadyCancelled)); + const nextId = useRef(4); + const submit = useSubmission(); + const insets = useSafeAreaInsets(); + const error = candidateError(candidates); + const mayShare = !error && lesson.parentConsented; + function update(id: string, patch: Partial) { + if (submit.pending) return; + submit.clearError(); + setCandidates((current) => current.map((c) => (c.id === id ? { ...c, ...patch } : c))); + } + function close() { + if (submit.pending) return; + Keyboard.dismiss(); + if (picker) setPicker(null); + else onClose(); + } + function register() { + Keyboard.dismiss(); + if (registered) { + setStep('candidates'); + return; + } + void submit.run( + (signal) => actions.registerCancellation(lesson.id, signal), + () => { + setRegistered(true); + setStep('candidates'); + }, + ); + } + function share() { + if (!mayShare || submit.pending) return; + Keyboard.dismiss(); + const snapshot = candidates.map((c) => ({ ...c, date: normalizeDate(c.date)! })); + void submit.run( + (signal) => actions.shareCandidates(lesson.id, snapshot, signal), + (result) => + setNotice( + result === 'opened' + ? '카카오 공유 창을 열었어요. 실제 전송 여부는 카카오톡에서 확인해 주세요.' + : result === 'cancelled' + ? '공유를 취소했어요. 보강 후보는 그대로 유지했어요.' + : '카카오 공유는 아직 연결되지 않았어요. 선택한 후보를 확인했으며 실제 메시지는 보내지 않았습니다.', + ), + ); + } + function reschedule() { + if (!validTime(newTime) || newTime === lesson.time || submit.pending) return; + void submit.run( + (signal) => actions.rescheduleTime(lesson.id, newTime, signal), + () => + setNotice('시간 변경 요청을 처리했어요. 확인용 화면에서는 실제 일정이 변경되지 않습니다.'), + ); + } + const target = picker && 'id' in picker ? candidates.find((c) => c.id === picker.id) : undefined; + return ( + + + + + + {notice ? ( + <> + + 처리 안내 + + + {notice} + + setNotice(null)} /> + + ) : picker ? ( + <> + {picker.kind === 'date' && target ? ( + { + update(target.id, { date: date.replaceAll('-', '.') }); + setPicker(null); + }} + onCancel={() => setPicker(null)} + /> + ) : ( + { + if (picker.kind === 'reschedule-time') setNewTime(time); + else if (target) update(target.id, { time }); + setPicker(null); + }} + onCancel={() => setPicker(null)} + /> + )} + + ) : ( + <> + + {step === 'action' ? ( + <> + + {lesson.dateLabel} {lesson.studentName} 수업 + + + 휴강하면 보강 날짜를 학부모님께 물어볼 수 있어요. + + + + ) : step === 'candidates' ? ( + <> + + 보강 후보 날짜 고르기 + + + {lesson.dateLabel} {lesson.studentName} 수업을 휴강해요. 가능한 날짜를 1~3개 + 고르면 학부모님이 그중 하나를 고르고, 안 맞으면 다른 날짜를 요청해요. + + {candidates.map((candidate, index) => ( + + + + 보강 후보 {index + 1} + {index === 0 ? '*' : ''} + + {index > 0 && ( + { + submit.clearError(); + + setCandidates((current) => + current.filter((c) => c.id !== candidate.id), + ); + }} + style={s.remove} + > + × + + )} + + + + { + Keyboard.dismiss(); + setPicker({ kind: 'date', id: candidate.id }); + }} + style={s.dateButton} + > + + {candidate.date || '날짜 선택'} + + + + { + Keyboard.dismiss(); + setPicker({ kind: 'time', id: candidate.id }); + }} + style={s.time} + > + {timeLabel(candidate.time)} + ⌃ + + + + ))} + {candidates.length < 3 && ( + { + submit.clearError(); + const id = `candidate-${nextId.current++}`; + setCandidates((current) => + current.length < 3 ? [...current, { id, date: '', time: '' }] : current, + ); + }} + style={s.add} + > + 후보 추가 ({candidates.length}/3) + + )} + {error && candidates.every((c) => c.date && c.time) && ( + + {error} + + )} + {!lesson.parentConsented && ( + + 학부모 수신 동의 후 보강 후보를 공유할 수 있어요. + + )} + + ) : ( + <> + + 수업 시간만 바꾸기 + + + {lesson.dateLabel} {lesson.studentName} 수업 날짜는 유지하고 시간만 변경해요. + + setPicker({ kind: 'reschedule-time' })} + style={s.time} + > + {timeLabel(newTime)} + ⌃ + + + )} + + {submit.error && ( + + 처리하지 못했어요. 입력값은 유지했으니 다시 시도해 주세요. + + )} + + {step === 'action' ? ( + <> + + { + submit.clearError(); + setStep('reschedule'); + }} + /> + + ) : step === 'candidates' ? ( + <> + + + + ) : ( + <> + + { + submit.clearError(); + setStep('action'); + }} + /> + + )} + + + )} + + + + ); +} diff --git a/src/features/makeup/dateTime.ts b/src/features/makeup/dateTime.ts new file mode 100644 index 0000000..239bb55 --- /dev/null +++ b/src/features/makeup/dateTime.ts @@ -0,0 +1,39 @@ +import type { MakeupCandidate } from './types'; +export function normalizeDate(value: string): string | null { + const match = /^(\d{4})[.-](\d{1,2})[.-](\d{1,2})$/.exec(value.trim()); + if (!match) return null; + const year = Number(match[1]), + month = Number(match[2]), + day = Number(match[3]); + if ( + year < 1900 || + year > 2100 || + month < 1 || + month > 12 || + day < 1 || + day > new Date(Date.UTC(year, month, 0)).getUTCDate() + ) + return null; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} +export function validTime(value: string) { + return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value); +} +export function timeLabel(value: string) { + if (!validTime(value)) return '시간 선택'; + const [hour, minute] = value.split(':').map(Number); + return `${hour < 12 ? '오전' : '오후'} ${hour % 12 || 12}시${minute ? ` ${minute}분` : ''}`; +} +export function candidateError(candidates: MakeupCandidate[]) { + if (candidates.length < 1 || candidates.length > 3) + return '보강 후보는 1~3개까지 선택할 수 있어요.'; + if (candidates.some((c) => !normalizeDate(c.date) || !validTime(c.time))) + return '날짜와 시간을 골라주세요.'; + const keys = candidates.map((c) => `${normalizeDate(c.date)} ${c.time}`); + if (new Set(keys).size !== keys.length) return '같은 날짜와 시간이 중복되지 않게 골라주세요.'; + return ''; +} +export const times = Array.from( + { length: 48 }, + (_, i) => `${String(Math.floor(i / 2)).padStart(2, '0')}:${i % 2 ? '30' : '00'}`, +); diff --git a/src/features/makeup/preview.tsx b/src/features/makeup/preview.tsx new file mode 100644 index 0000000..c6b21d5 --- /dev/null +++ b/src/features/makeup/preview.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { Redirect, useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { View } from 'react-native'; +import { ActionButton } from '@/components/ActionButton'; +import { MakeupSheet } from './MakeupSheet'; +import { createPreviewActions } from './previewActions'; +import { s } from './styles'; +export function MakeupPreview() { + const params = useLocalSearchParams<{ + step?: string; + count?: string; + scenario?: string; + consent?: string; + }>(); + const [visible, setVisible] = useState(true); + const [actions] = useState(() => createPreviewActions(params.scenario)); + if (!__DEV__) return ; + const count = Math.min(3, Math.max(1, Number(params.count) || 1)); + return ( + + + setVisible(true)} /> + + {visible && ( + ({ + id: `candidate-${i + 1}`, + date: `2026.10.${22 + i}`, + time: '20:00', + })) + : undefined + } + onClose={() => setVisible(false)} + /> + )} + + ); +} diff --git a/src/features/makeup/previewActions.ts b/src/features/makeup/previewActions.ts new file mode 100644 index 0000000..542fe32 --- /dev/null +++ b/src/features/makeup/previewActions.ts @@ -0,0 +1,30 @@ +import type { MakeupActions } from './types'; +export function createPreviewActions(scenario?: string): MakeupActions { + let attempt = 0; + async function work(signal: AbortSignal) { + 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 failure'); + } + return { + registerCancellation: async (_, signal) => work(signal), + shareCandidates: async (_, __, signal) => { + await work(signal); + return scenario === 'share-cancelled' ? 'cancelled' : 'unavailable'; + }, + rescheduleTime: async (_, __, signal) => work(signal), + }; +} diff --git a/src/features/makeup/styles.ts b/src/features/makeup/styles.ts new file mode 100644 index 0000000..16d5c3c --- /dev/null +++ b/src/features/makeup/styles.ts @@ -0,0 +1,128 @@ +import { StyleSheet } from 'react-native'; +import { semantic as c } from '@/styles/colors'; +import { typography as t } from '@/styles/typography'; +import { radius, padding, gap } from '@/styles/measurement'; +export const s = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: '#2D303D80', + justifyContent: 'flex-end', + alignItems: 'center', + }, + dismiss: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, + sheet: { + width: '100%', + maxWidth: 390, + maxHeight: '92%', + backgroundColor: c.surface.subtle, + borderTopLeftRadius: radius.large, + borderTopRightRadius: radius.large, + paddingHorizontal: padding[24], + }, + handle: { + width: 56, + height: 4, + backgroundColor: c.border.default, + borderRadius: radius.full, + alignSelf: 'center', + marginTop: gap[12], + marginBottom: gap[24], + }, + heading: { ...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] }, + content: { paddingBottom: padding[12] }, + actions: { gap: gap[8], paddingTop: padding[12] }, + actionSpacing: { height: 32 }, + candidate: { marginBottom: gap[12] }, + candidateHeading: { + minHeight: 28, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: gap[4], + }, + label: { ...t.body[5].medium, color: c.text.default.normal, marginLeft: padding[8] }, + remove: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + removeText: { fontSize: 24, color: c.icon.primary, fontWeight: '300' }, + row: { + flexDirection: 'row', + gap: gap[8], + alignItems: 'flex-start', + width: '100%', + maxWidth: 316, + }, + dateColumn: { flex: 1 }, + time: { + width: 96, + minHeight: 52, + borderWidth: 1, + borderColor: c.border.default, + borderRadius: radius.small, + paddingHorizontal: padding[8], + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: gap[8], + backgroundColor: c.surface.input, + }, + timeText: { ...t.body[5].medium, color: c.text.default.normal }, + caret: { color: c.icon.secondary, fontSize: 12 }, + dateButton: { + minHeight: 52, + borderWidth: 1, + borderColor: c.border.default, + borderRadius: radius.small, + backgroundColor: c.surface.input, + justifyContent: 'center', + paddingHorizontal: padding[12], + }, + placeholder: { color: c.text.default.weak }, + add: { + minHeight: 44, + justifyContent: 'center', + alignSelf: 'flex-start', + paddingLeft: padding[8], + marginBottom: gap[16], + }, + addText: { ...t.body[4].semiBold, color: c.text.brand.assistive }, + error: { ...t.caption[1].regular, color: c.text.feedback.warning, marginBottom: gap[8] }, + monthHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: gap[16], + }, + monthText: { ...t.body[2].semiBold, color: c.text.default.normal }, + monthButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + monthArrow: { fontSize: 24, color: c.icon.primary }, + week: { flexDirection: 'row' }, + day: { + width: '14.285714%', + height: 44, + justifyContent: 'center', + alignItems: 'center', + borderRadius: radius.small, + }, + dayText: { ...t.body[4].regular, color: c.text.default.normal }, + selectedDay: { backgroundColor: c.action.default }, + selectedDayText: { color: c.text.default.inverse }, + weekText: { ...t.caption[1].medium, color: c.text.default.weak }, + timeOption: { + minHeight: 44, + paddingHorizontal: padding[16], + justifyContent: 'center', + borderRadius: radius.small, + }, + selectedTime: { backgroundColor: c.surface.focused }, + timeOptionText: { ...t.body[3].medium, color: c.text.default.normal }, + timeList: { maxHeight: 300 }, + preview: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: padding[24], + backgroundColor: c.surface.input, + }, + previewButton: { width: '100%', maxWidth: 342 }, + notice: { ...t.body[3].regular, color: c.text.default.normal, marginBottom: gap[16] }, +}); diff --git a/src/features/makeup/types.ts b/src/features/makeup/types.ts new file mode 100644 index 0000000..63421ee --- /dev/null +++ b/src/features/makeup/types.ts @@ -0,0 +1,21 @@ +export type MakeupCandidate = { id: string; date: string; time: string }; +export type MakeupLesson = { + id: string; + studentName: string; + dateLabel: string; + date: string; + time: string; + parentConsented: boolean; + alreadyCancelled?: boolean; +}; +export type CandidateShareResult = 'opened' | 'cancelled' | 'unavailable'; +/** 화면 내부 모델입니다. 실제 API 계약은 확정 후 변환합니다. */ +export type MakeupActions = { + registerCancellation: (lessonId: string, signal: AbortSignal) => Promise; + shareCandidates: ( + lessonId: string, + candidates: MakeupCandidate[], + signal: AbortSignal, + ) => Promise; + rescheduleTime: (lessonId: string, time: string, signal: AbortSignal) => Promise; +}; From c15ddb90023f64a6ce06aff76bdec7998ceb1e67 Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 01:18:06 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=ED=9C=B4=EA=B0=95=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EC=A1=B0=EC=9C=A8=20=EC=8B=9C=ED=8A=B8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/session-record/preview.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/features/session-record/preview.tsx b/src/features/session-record/preview.tsx index 2d40c60..7f95ce8 100644 --- a/src/features/session-record/preview.tsx +++ b/src/features/session-record/preview.tsx @@ -4,6 +4,8 @@ import { Modal, Text, View } from 'react-native'; import { ActionButton } from '@/components/ActionButton'; import { SessionRecordScreen } from './SessionRecordScreen'; import { RecordComplete } from './RecordComplete'; +import { MakeupSheet } from '@/features/makeup/MakeupSheet'; +import { createPreviewActions } from '@/features/makeup/previewActions'; import { s } from './styles'; import type { CancellationReason, @@ -49,6 +51,8 @@ export function SessionPreview({ const [notice, setNotice] = useState< 'coordinate' | 'parent-summary' | 'student-homework' | 'report' | null >(null); + const [makeupOpen, setMakeupOpen] = useState(false); + const [makeupActions] = useState(() => createPreviewActions(params.scenario)); const recordAttempts = useRef(0); const cancelAttempts = useRef(0); if (!__DEV__) return ; @@ -138,7 +142,7 @@ export function SessionPreview({ onSaveCancellation={saveCancellation} onBack={() => router.replace('/home')} onHome={() => router.replace('/home')} - onCoordinate={() => setNotice('coordinate')} + onCoordinate={() => setMakeupOpen(true)} onShare={setNotice} onReport={() => setNotice('report')} initialFilled={variant !== 'blank' && !empty} @@ -151,6 +155,22 @@ export function SessionPreview({ } /> )} + {makeupOpen && ( + setMakeupOpen(false)} + /> + )} setNotice(null)}>