diff --git a/mobile/README.md b/mobile/README.md index 87b0eca..64fbe71 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -7,6 +7,19 @@ React Native + Expo(expo-router, TypeScript) 모바일 앱. 조회·승인·현 - Expo SDK 51 · expo-router ~3.5 · React Native 0.74 · TypeScript - JWT는 `expo-secure-store`(네이티브 keychain/keystore)에 저장, 웹은 AsyncStorage 폴백 - 디자인 토큰: `theme/`(docs/design.md §1 팔레트·타이포). 신규 토큰 없음. +- 다국어: `react-i18next` + `expo-localization`(SDK51 호환). 지원 ko(기본/폴백)·en·zh·ja. + +## 다국어(i18n) +- 초기화 `lib/i18n/`(동기 init — 디바이스 로케일 감지, 저장 선택은 부팅 시 반영). 리소스 `lib/i18n/locales/{ko,en,zh,ja}.ts`. +- **ko가 키 형상의 단일 출처** — en/zh/ja는 `Translations`(DeepString) 타입 구현이라 키 누락 시 tsc가 잡음(병렬 편집 함정 회피: 직렬·키 대조). +- 언어 선택 지속: AsyncStorage 키 `kintex_lang`. 전환 UI `components/LanguageSelector.tsx`(더보기·내정보에 배치), 상태 `context/LanguageContext.tsx`. +- 커버 화면(우선순위): 로그인/2FA·회원가입·비번찾기·홈·현장·갤러리·더보기·내정보·티켓 지갑·탭/스택 타이틀·역할 라벨. 미커버(폴백=현행 한국어): `checklist`·`inspection`·`tickets/select`·티켓 카드/필터탭 컴포넌트(`components/tickets/*`). + +## 역할별 랜딩 +- `lib/roleTrack.ts` — 웹 `src/frontend/src/lib/roleTrack.ts` 패리티(우선순위 admin>ops>business>agency>visitor, 미판정=visitor 기본). +- 로그인 응답 user에 roleCode가 없어 `hallManager`를 ops 신호로 사용(roleCode 아는 호출부는 옵션 전달). +- 모바일 랜딩 매핑: admin/ops/business→`/(tabs)` 홈 · agency(장치·시공)→`/(tabs)/field` · visitor(관람객)→`/tickets`. +- 콜드 부팅 시 workspaces 미복원 misroute 방지 위해 로그인 시 계산한 랜딩 경로를 secure-store에 지속(`AuthContext`). ## 실행 ```bash @@ -48,5 +61,6 @@ API 베이스는 `app.json > extra.apiBase` 또는 `EXPO_PUBLIC_API_BASE` 환경 ## 남은 작업 - 실 화면 데이터 API 배선 확대(부스 목록·검수 요청 제출 — 백엔드 M6/C-4 확장 대기). -- 앱 아이콘/스플래시 에셋(`assets/`), i18n, 푸시 알림. -- EAS 실빌드(eas.json 프로파일) — 본 스캐폴드 범위 밖. +- 앱 아이콘/스플래시 에셋(`assets/`), 푸시 알림. +- i18n 잔여 화면 키 추출: `checklist`·`inspection`·`tickets/select`·`components/tickets/*`(현재 한국어 폴백). +- EAS 실빌드(eas.json 프로파일) — G3 게이트(소유자 승인). diff --git a/mobile/app.json b/mobile/app.json index c4032f3..21dd44e 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -4,7 +4,7 @@ "slug": "kintex", "owner": "zioinfo", "scheme": "kintex", - "version": "0.1.0", + "version": "0.2.0", "orientation": "portrait", "userInterfaceStyle": "light", "jsEngine": "hermes", @@ -28,7 +28,7 @@ }, "android": { "package": "kr.co.zioinfo.kintex", - "versionCode": 1, + "versionCode": 2, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#0066B3" @@ -58,7 +58,6 @@ "expo-router", "expo-secure-store", "expo-font", - "expo-screen-capture", [ "expo-image-picker", { diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index fccc948..f364588 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -5,10 +5,12 @@ import { Ionicons } from '@expo/vector-icons'; import { Redirect, Tabs } from 'expo-router'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '../../context/AuthContext'; import { colors } from '../../theme'; export default function TabsLayout() { + const { t } = useTranslation(); const { ready, token } = useAuth(); if (ready && !token) return ; @@ -25,14 +27,14 @@ export default function TabsLayout() { , }} /> ( ), @@ -41,14 +43,14 @@ export default function TabsLayout() { , }} /> ( ), diff --git a/mobile/app/(tabs)/field.tsx b/mobile/app/(tabs)/field.tsx index b3c1912..0f6be33 100644 --- a/mobile/app/(tabs)/field.tsx +++ b/mobile/app/(tabs)/field.tsx @@ -5,12 +5,14 @@ import { Ionicons } from '@expo/vector-icons'; import { router } from 'expo-router'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Banner } from '../../components/Banner'; import { useAuth } from '../../context/AuthContext'; import { colors, radius, spacing, type } from '../../theme'; export default function FieldScreen() { + const { t } = useTranslation(); const { activeWorkspace, user } = useAuth(); const role = activeWorkspace?.myRole; const isHallManager = user?.hallManager || role === 'HALL_MANAGER'; @@ -18,19 +20,17 @@ export default function FieldScreen() { return ( - 현장 업무 + {t('field.title')} {!isContractor && !isHallManager ? ( - - 현장 기능은 장치·시공업체(체크리스트)와 홀매니저(검수) 역할에서 제공됩니다. - + {t('field.guide')} ) : null} {isContractor ? ( router.push('/checklist')} /> ) : null} @@ -38,8 +38,8 @@ export default function FieldScreen() { {isHallManager ? ( router.push('/inspection')} /> ) : null} diff --git a/mobile/app/(tabs)/gallery.tsx b/mobile/app/(tabs)/gallery.tsx index 3826b97..03bc473 100644 --- a/mobile/app/(tabs)/gallery.tsx +++ b/mobile/app/(tabs)/gallery.tsx @@ -4,7 +4,8 @@ * 실 API 호출 구조를 유지하되, 서버 미구현/오프라인 시 degraded 샘플로 폴백한다. */ import { router } from 'expo-router'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { AiImage } from '../../components/AiImage'; import { Banner } from '../../components/Banner'; @@ -18,14 +19,21 @@ import { colors, spacing, type } from '../../theme'; const DEMO_BOOTH = 'b-102'; // degraded 폴백 샘플 (서버 미구현/오프라인 시 표시 — 항상 워터마크 유지) -const FALLBACK: RenderJobDto[] = [ - mkJob('job-s1', 'S1', 'DONE'), - mkJob('job-s2', 'S2', 'DONE'), - mkJob('job-s3', 'S3', 'RUNNING'), - mkJob('job-s7', 'S7', 'FAILED'), -]; +function buildFallback(notice: string): RenderJobDto[] { + return [ + mkJob('job-s1', 'S1', 'DONE', notice), + mkJob('job-s2', 'S2', 'DONE', notice), + mkJob('job-s3', 'S3', 'RUNNING', notice), + mkJob('job-s7', 'S7', 'FAILED', notice), + ]; +} -function mkJob(jobId: string, shot: string, status: RenderJobDto['status']): RenderJobDto { +function mkJob( + jobId: string, + shot: string, + status: RenderJobDto['status'], + notice: string, +): RenderJobDto { return { jobId, boothId: DEMO_BOOTH, @@ -36,7 +44,7 @@ function mkJob(jobId: string, shot: string, status: RenderJobDto['status']): Ren modelVersion: null, watermarkRequired: true, watermarkText: AI_IMAGE_NOTICE, - notice: 'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 (시공 기준은 도면)', + notice, }; } @@ -51,15 +59,18 @@ const SHOT_LABEL: Record = { }; export default function GalleryScreen() { + const { t } = useTranslation(); const { activeWorkspace, token } = useAuth(); const [jobs, setJobs] = useState([]); const [degraded, setDegraded] = useState(false); const [loading, setLoading] = useState(true); + const fallback = useMemo(() => buildFallback(t('gallery.notice')), [t]); + const load = useCallback(async () => { setLoading(true); if (!activeWorkspace || !token) { - setJobs(FALLBACK); + setJobs(fallback); setDegraded(true); setLoading(false); return; @@ -72,7 +83,7 @@ export default function GalleryScreen() { setDegraded(false); } catch (e) { if (isDegraded(e)) { - setJobs(FALLBACK); + setJobs(fallback); setDegraded(true); } else { setJobs([]); @@ -81,7 +92,7 @@ export default function GalleryScreen() { } finally { setLoading(false); } - }, [activeWorkspace, token]); + }, [activeWorkspace, token, fallback]); useEffect(() => { load(); @@ -91,9 +102,7 @@ export default function GalleryScreen() { {/* 상단 고정 고지 바 (제거 불가) */} - - AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다 - + {t('gallery.infoBar')} } > {degraded ? ( - - 서버 이미지 이력에 연결할 수 없어 예시를 표시합니다 (degraded). - + {t('gallery.degraded')} ) : null} {jobs.length === 0 && !loading ? ( - - 아직 생성된 이미지가 없습니다 — 설계 스튜디오에서 예상 사진을 생성하세요. - + {t('gallery.empty')} ) : null} @@ -124,14 +129,14 @@ export default function GalleryScreen() { height={130} /> {j.status === 'FAILED' ? ( -