diff --git a/.gitignore b/.gitignore index e15b23a..1419237 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,8 @@ apps/web/test-results/ apps/web/playwright-report/ apps/desktop/test-results/ +# Gradle-generated vector-icon drawables (react-native-vector-icons), not source. +apps/mobile-rn/android/app/src/main/res/drawable-*/node_modules_* # Supabase CLI local state /supabase/.branches/ /supabase/.temp/ diff --git a/apps/mobile-rn/__tests__/teams.test.ts b/apps/mobile-rn/__tests__/teams.test.ts index a418ded..91f3a53 100644 --- a/apps/mobile-rn/__tests__/teams.test.ts +++ b/apps/mobile-rn/__tests__/teams.test.ts @@ -29,11 +29,13 @@ import { acceptTeamInvite, cancelTeamInvite, createTeam, + createTeamActivity, createTeamInvite, extractInviteToken, normalizeInviteEmail, normalizeInviteToken, normalizeInviteUrl, + normalizeTeamActivity, normalizeTeamInvite, normalizeTeamMember, normalizeTeamName, @@ -293,6 +295,57 @@ describe('team mutation safety', () => { }) }) +describe('team activity feed contract', () => { + test('normalizes a valid activity and rejects malformed rows', () => { + const activity = normalizeTeamActivity({ + id: INVITE_ID, + team_id: TEAM_ID, + actor_id: USER_ID, + kind: 'note', + body: 'hello', + created_at: '2026-01-01T00:00:00.000Z', + }) + expect(activity).toEqual({ + id: INVITE_ID, + teamId: TEAM_ID, + actorId: USER_ID, + kind: 'note', + body: 'hello', + createdAt: '2026-01-01T00:00:00.000Z', + }) + expect(() => normalizeTeamActivity({ id: 'not-a-uuid', team_id: TEAM_ID, kind: 'note' })) + .toThrow(TeamServiceError) + }) + + test('posts a trimmed note through the RPC and rejects empty notes', async () => { + mockRpc.mockResolvedValue({ + data: { + id: INVITE_ID, + team_id: TEAM_ID, + actor_id: USER_ID, + kind: 'note', + body: 'hello', + created_at: '2026-01-01T00:00:00.000Z', + }, + error: null, + }) + const created = await createTeamActivity(TEAM_ID, 'note', ' hello ') + expect(mockRpc).toHaveBeenCalledWith('create_team_activity', { + p_team_id: TEAM_ID, + p_kind: 'note', + p_body: 'hello', + p_metadata: {}, + }) + expect(created.body).toBe('hello') + + mockRpc.mockClear() + await expect(createTeamActivity(TEAM_ID, 'note', ' ')).rejects.toMatchObject({ + code: 'validation', + }) + expect(mockRpc).not.toHaveBeenCalled() + }) +}) + describe('team realtime contract', () => { test('filters every mutable team table by the selected team', () => { mockRealtimeOn.mockClear() @@ -306,6 +359,7 @@ describe('team realtime contract', () => { { event: '*', schema: 'public', table: 'team_members', filter: `team_id=eq.${TEAM_ID}` }, { event: '*', schema: 'public', table: 'team_invites', filter: `team_id=eq.${TEAM_ID}` }, { event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${TEAM_ID}` }, + { event: '*', schema: 'public', table: 'team_activities', filter: `team_id=eq.${TEAM_ID}` }, ]) }) }) diff --git a/apps/mobile-rn/src/components/ContentReportSheet.tsx b/apps/mobile-rn/src/components/ContentReportSheet.tsx index 63672cc..b2a0e16 100644 --- a/apps/mobile-rn/src/components/ContentReportSheet.tsx +++ b/apps/mobile-rn/src/components/ContentReportSheet.tsx @@ -283,7 +283,7 @@ function createStyles(palette: Palette): ReturnType { overlay: { flex: 1, justifyContent: 'flex-end', - backgroundColor: 'rgba(0, 0, 0, 0.62)', + backgroundColor: palette.scrim, }, keyboardArea: { width: '100%', justifyContent: 'flex-end' }, sheet: { diff --git a/apps/mobile-rn/src/features/teams/team-service.ts b/apps/mobile-rn/src/features/teams/team-service.ts index 6f6301c..f61a1ec 100644 --- a/apps/mobile-rn/src/features/teams/team-service.ts +++ b/apps/mobile-rn/src/features/teams/team-service.ts @@ -38,12 +38,30 @@ export interface TeamAcceptResult { alreadyMember: boolean } +export type TeamActivityKind = + | 'note' + | 'member_joined' + | 'member_left' + | 'invite_created' + | 'meeting_shared' + | 'document_shared' + +export interface TeamActivityEntry { + id: string + teamId: string + actorId: string | null + kind: TeamActivityKind + body: string | null + createdAt: string +} + export interface TeamDetail { team: Team currentRole: TeamRole members: TeamMemberDirectoryEntry[] invites: TeamInviteListEntry[] meetings: Meeting[] + activities: TeamActivityEntry[] } export type TeamServiceErrorCode = @@ -262,6 +280,46 @@ export function normalizeTeamInvite(value: unknown): TeamInviteListEntry { } } +const TEAM_ACTIVITY_KINDS: readonly TeamActivityKind[] = [ + 'note', + 'member_joined', + 'member_left', + 'invite_created', + 'meeting_shared', + 'document_shared', +] + +function isActivityKind(value: unknown): value is TeamActivityKind { + return typeof value === 'string' && (TEAM_ACTIVITY_KINDS as readonly string[]).includes(value) +} + +export function normalizeTeamActivity(value: unknown): TeamActivityEntry { + if (!isRecord(value)) { + throw new TeamServiceError('invalid-response', 'Team activity response is not an object') + } + const actorId = value.actor_id ?? value.actorId ?? null + const body = value.body ?? null + if ( + typeof value.id !== 'string' + || !UUID_PATTERN.test(value.id) + || typeof value.team_id !== 'string' + || !isActivityKind(value.kind) + || (actorId !== null && typeof actorId !== 'string') + || (body !== null && typeof body !== 'string') + || !isIsoDate(value.created_at) + ) { + throw new TeamServiceError('invalid-response', 'Team activity response is malformed') + } + return { + id: value.id, + teamId: value.team_id, + actorId, + kind: value.kind, + body, + createdAt: value.created_at, + } +} + function errorDetails(error: unknown): { code: string; message: string } { const candidate = error as { code?: unknown @@ -441,11 +499,63 @@ async function listTeamInvites(teamId: string): Promise { return data.map(normalizeTeamInvite) } +export async function listTeamActivities(teamId: string, limit = 50): Promise { + requireUuid(teamId, 'Team') + const capped = Math.max(1, Math.min(Math.floor(limit), 100)) + try { + const { data, error } = await supabase + .from('team_activities') + .select('*') + .eq('team_id', teamId) + .order('created_at', { ascending: false }) + .limit(capped) + if (error !== null) throw error + return (data ?? []).map(normalizeTeamActivity) + } catch (error) { + throw toTeamServiceError(error) + } +} + +export async function createTeamActivity( + teamId: string, + kind: TeamActivityKind, + body: string | null, +): Promise { + requireUuid(teamId, 'Team') + if (!isActivityKind(kind)) { + throw new TeamServiceError('validation', 'Team activity kind is invalid') + } + const trimmed = typeof body === 'string' ? body.trim() : '' + if (kind === 'note' && trimmed.length === 0) { + throw new TeamServiceError('validation', 'Team activity body is required') + } + if (trimmed.length > 2000) { + throw new TeamServiceError('validation', 'Team activity body is too long') + } + const result = await invokeRpc('create_team_activity', { + p_team_id: teamId, + p_kind: kind, + p_body: trimmed === '' ? null : trimmed, + p_metadata: {}, + }) + if (!isRecord(result) || typeof result.id !== 'string' || !UUID_PATTERN.test(result.id)) { + throw new TeamServiceError('invalid-response', 'Team activity response is malformed') + } + return { + id: result.id, + teamId, + actorId: typeof result.actor_id === 'string' ? result.actor_id : null, + kind, + body: trimmed === '' ? null : trimmed, + createdAt: typeof result.created_at === 'string' ? result.created_at : new Date().toISOString(), + } +} + export async function getTeamDetail(userId: string, teamId: string): Promise { requireUuid(userId, 'Authenticated user') requireUuid(teamId, 'Team') try { - const [teamResult, members, meetingsResult] = await Promise.all([ + const [teamResult, members, meetingsResult, activitiesResult] = await Promise.all([ supabase.from('teams').select('*').eq('id', teamId).maybeSingle(), listTeamMembers(teamId), supabase @@ -454,12 +564,19 @@ export async function getTeamDetail(userId: string, teamId: string): Promise member.userId === userId) if (ownMembership === undefined) { @@ -473,7 +590,8 @@ export async function getTeamDetail(userId: string, teamId: string): Promise meeting.team_id !== teamId)) { throw new TeamServiceError('invalid-response', 'Meeting response crossed team boundary') } - return { team, currentRole: ownMembership.role, members, invites, meetings } + const activities = (activitiesResult.data ?? []).map(normalizeTeamActivity) + return { team, currentRole: ownMembership.role, members, invites, meetings, activities } } catch (error) { throw toTeamServiceError(error) } @@ -681,6 +799,11 @@ export function subscribeToTeam( { event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${teamId}` }, onRemoteChange, ) + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'team_activities', filter: `team_id=eq.${teamId}` }, + onRemoteChange, + ) .subscribe(onStatus) return { diff --git a/apps/mobile-rn/src/screens/AdminUserDetailScreen.tsx b/apps/mobile-rn/src/screens/AdminUserDetailScreen.tsx index d582ae7..04cfb62 100644 --- a/apps/mobile-rn/src/screens/AdminUserDetailScreen.tsx +++ b/apps/mobile-rn/src/screens/AdminUserDetailScreen.tsx @@ -840,7 +840,7 @@ function createStyles(palette: Palette) { flex: 1, justifyContent: 'center', padding: 18, - backgroundColor: 'rgba(0,0,0,0.72)', + backgroundColor: palette.scrim, }, modalCard: { maxHeight: '88%', padding: 0, overflow: 'hidden' }, modalContent: { padding: 18, gap: 16 }, diff --git a/apps/mobile-rn/src/screens/CommandsScreen.tsx b/apps/mobile-rn/src/screens/CommandsScreen.tsx index 2fae366..ea75660 100644 --- a/apps/mobile-rn/src/screens/CommandsScreen.tsx +++ b/apps/mobile-rn/src/screens/CommandsScreen.tsx @@ -603,7 +603,7 @@ function createStyles(palette: Palette): ReturnType { output: { gap: 7, padding: 12, borderWidth: 1, borderRadius: 9, borderColor: palette.accent.main }, outputHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, outputText: { lineHeight: 21 }, - backdrop: { flex: 1, justifyContent: 'flex-end', padding: 18, backgroundColor: 'rgba(0,0,0,0.65)' }, + backdrop: { flex: 1, justifyContent: 'flex-end', padding: 18, backgroundColor: palette.scrim }, editor: { gap: 13, maxHeight: '92%' }, editorActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 9 }, }) diff --git a/apps/mobile-rn/src/screens/DictionaryScreen.tsx b/apps/mobile-rn/src/screens/DictionaryScreen.tsx index c5c9799..a908a44 100644 --- a/apps/mobile-rn/src/screens/DictionaryScreen.tsx +++ b/apps/mobile-rn/src/screens/DictionaryScreen.tsx @@ -673,7 +673,7 @@ function createStyles(palette: Palette): ReturnType { alignItems: 'center', justifyContent: 'center', padding: 20, - backgroundColor: 'rgba(0,0,0,0.72)', + backgroundColor: palette.scrim, }, modalCard: { width: '100%', diff --git a/apps/mobile-rn/src/screens/MeetingDetailScreen.tsx b/apps/mobile-rn/src/screens/MeetingDetailScreen.tsx index 978b48d..4eb4ad3 100644 --- a/apps/mobile-rn/src/screens/MeetingDetailScreen.tsx +++ b/apps/mobile-rn/src/screens/MeetingDetailScreen.tsx @@ -1152,7 +1152,7 @@ function createStyles(palette: Palette): ReturnType { flex: 1, padding: 20, justifyContent: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.62)', + backgroundColor: palette.scrim, }, modalCard: { gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, editorInput: { diff --git a/apps/mobile-rn/src/screens/MeetingsScreen.tsx b/apps/mobile-rn/src/screens/MeetingsScreen.tsx index 90bb3e1..e52fe15 100644 --- a/apps/mobile-rn/src/screens/MeetingsScreen.tsx +++ b/apps/mobile-rn/src/screens/MeetingsScreen.tsx @@ -772,7 +772,7 @@ function createStyles(palette: Palette): ReturnType { flex: 1, padding: 20, justifyContent: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.62)', + backgroundColor: palette.scrim, }, modalCard: { gap: 14, maxWidth: 560, maxHeight: '92%', width: '100%', alignSelf: 'center' }, modalScroll: { flexShrink: 1 }, diff --git a/apps/mobile-rn/src/screens/MemosScreen.tsx b/apps/mobile-rn/src/screens/MemosScreen.tsx index a35a9f4..97561c4 100644 --- a/apps/mobile-rn/src/screens/MemosScreen.tsx +++ b/apps/mobile-rn/src/screens/MemosScreen.tsx @@ -565,7 +565,7 @@ function createStyles(palette: Palette): ReturnType { tag: { minHeight: 38, justifyContent: 'center', paddingHorizontal: 10, borderRadius: 999, backgroundColor: palette.accent.dim }, addRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, addInput: { flex: 1, marginHorizontal: 0 }, - modalBackdrop: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.68)' }, + modalBackdrop: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: palette.scrim }, modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 }, actions: { flexDirection: 'row', alignItems: 'center', gap: 10 }, flexButton: { flex: 1 }, diff --git a/apps/mobile-rn/src/screens/TeamDetailScreen.tsx b/apps/mobile-rn/src/screens/TeamDetailScreen.tsx index 66ad1e6..f1b7375 100644 --- a/apps/mobile-rn/src/screens/TeamDetailScreen.tsx +++ b/apps/mobile-rn/src/screens/TeamDetailScreen.tsx @@ -22,6 +22,7 @@ import { useMobilePreferences } from '../lib/preferences-context' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' import { cancelTeamInvite, + createTeamActivity, createTeamInvite, deleteTeamRevisionSafe, getTeamDetail, @@ -70,6 +71,8 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac const [inviteRole, setInviteRole] = useState<'admin' | 'member'>('member') const [editorError, setEditorError] = useState(null) const [inviteResult, setInviteResult] = useState(null) + const [activityBody, setActivityBody] = useState('') + const [postingActivity, setPostingActivity] = useState(false) const load = useCallback(async (pull = false): Promise => { const requestGeneration = ++generation.current @@ -123,6 +126,29 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac const canAdminister = detail?.currentRole === 'owner' || detail?.currentRole === 'admin' const isOwner = detail?.currentRole === 'owner' + const memberNameById = useMemo(() => { + const map = new Map() + for (const member of detail?.members ?? []) { + map.set(member.userId, memberLabel(member, t('mobile.teams.unnamedMember'))) + } + return map + }, [detail, t]) + + const postActivity = async (): Promise => { + const body = activityBody.trim() + if (user === null || body.length === 0 || postingActivity) return + setPostingActivity(true) + try { + await createTeamActivity(teamId, 'note', body) + setActivityBody('') + await load(false) + } catch (postError) { + Alert.alert(t('mobile.teams.activity'), teamErrorMessage(teamErrorCode(postError), t)) + } finally { + setPostingActivity(false) + } + } + const openRename = (): void => { if (detail === null) return setName(detail.team.name) @@ -523,6 +549,45 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac ))} + + {t('mobile.teams.activity')} + + { void postActivity() }} + testID="team-activity-post" + /> + {detail.activities.length === 0 ? ( + {t('mobile.teams.noActivity')} + ) : detail.activities.map((activity) => ( + + + {activity.actorId !== null + ? memberNameById.get(activity.actorId) ?? t('mobile.teams.unnamedMember') + : t('mobile.teams.system')} + {' · '} + {formatDate(new Date(activity.createdAt), { + dateStyle: 'medium', + timeStyle: 'short', + })} + + {activity.body !== null && activity.body.length > 0 && ( + {activity.body} + )} + + ))} + + {t('mobile.teams.management')} {isOwner ? ( @@ -746,11 +811,30 @@ function createStyles(palette: Palette): ReturnType { backgroundColor: palette.bg.inset, gap: 5, }, + activityInput: { + minHeight: 52, + maxHeight: 140, + paddingHorizontal: 14, + paddingVertical: 10, + borderWidth: 1, + borderColor: palette.border.strong, + borderRadius: 10, + backgroundColor: palette.bg.inset, + color: palette.text.primary, + fontSize: 15, + textAlignVertical: 'top', + }, + activityRow: { + padding: 12, + borderRadius: 10, + backgroundColor: palette.bg.inset, + gap: 4, + }, modalBackdrop: { flex: 1, justifyContent: 'center', padding: 20, - backgroundColor: 'rgba(0,0,0,0.62)', + backgroundColor: palette.scrim, }, modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 }, input: { diff --git a/apps/mobile-rn/src/screens/TeamsScreen.tsx b/apps/mobile-rn/src/screens/TeamsScreen.tsx index 4581838..bd9bb07 100644 --- a/apps/mobile-rn/src/screens/TeamsScreen.tsx +++ b/apps/mobile-rn/src/screens/TeamsScreen.tsx @@ -303,7 +303,7 @@ function createStyles(palette: Palette): ReturnType { flex: 1, justifyContent: 'center', padding: 20, - backgroundColor: 'rgba(0,0,0,0.62)', + backgroundColor: palette.scrim, }, modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 }, input: { diff --git a/apps/mobile-rn/src/screens/TemplatesScreen.tsx b/apps/mobile-rn/src/screens/TemplatesScreen.tsx index 4c3ff38..91e775d 100644 --- a/apps/mobile-rn/src/screens/TemplatesScreen.tsx +++ b/apps/mobile-rn/src/screens/TemplatesScreen.tsx @@ -685,7 +685,7 @@ function createStyles(palette: Palette): ReturnType { badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, backgroundColor: palette.accent.dim }, actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, flexButton: { flex: 1 }, - modalBackdrop: { flex: 1, justifyContent: 'center', padding: 16, backgroundColor: 'rgba(0,0,0,0.68)' }, + modalBackdrop: { flex: 1, justifyContent: 'center', padding: 16, backgroundColor: palette.scrim }, modalList: { width: '100%', maxWidth: 680, maxHeight: '94%', alignSelf: 'center', borderRadius: 14, backgroundColor: palette.bg.card }, modalCard: { padding: 18, gap: 14 }, editorSection: { gap: 14 }, diff --git a/apps/mobile-rn/src/theme/mobile-theme.ts b/apps/mobile-rn/src/theme/mobile-theme.ts index 5aa3eef..bdcca6a 100644 --- a/apps/mobile-rn/src/theme/mobile-theme.ts +++ b/apps/mobile-rn/src/theme/mobile-theme.ts @@ -49,6 +49,19 @@ export interface MobileThemePalette { green: string blue: string } + /** v3: 시맨틱 상태 역할 (액센트와 분리) */ + status: { + success: string + successBg: string + warning: string + warningBg: string + danger: string + dangerBg: string + info: string + infoBg: string + } + /** v3: 모달 뒤 스크림 (테마 반응) */ + scrim: string led: { off: string } @@ -96,6 +109,17 @@ const DARK_PALETTE: MobileThemePalette = { green: '#4ade80', blue: '#60a5fa', }, + status: { + success: '#4ade80', + successBg: 'rgba(74, 222, 128, 0.14)', + warning: '#fbbf24', + warningBg: 'rgba(251, 191, 36, 0.14)', + danger: '#f87171', + dangerBg: 'rgba(248, 113, 113, 0.14)', + info: '#60a5fa', + infoBg: 'rgba(96, 165, 250, 0.14)', + }, + scrim: 'rgba(0, 0, 0, 0.6)', led: { off: '#111111' }, shadow: '#000000', } @@ -140,6 +164,17 @@ const LIGHT_PALETTE: MobileThemePalette = { green: '#167044', blue: '#175cd3', }, + status: { + success: '#15803d', + successBg: 'rgba(21, 128, 61, 0.10)', + warning: '#a25200', + warningBg: 'rgba(162, 82, 0, 0.10)', + danger: '#b42318', + dangerBg: 'rgba(180, 35, 24, 0.10)', + info: '#175cd3', + infoBg: 'rgba(23, 92, 211, 0.10)', + }, + scrim: 'rgba(0, 0, 0, 0.4)', led: { off: '#c6c6c2' }, shadow: '#4b4b50', } diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore deleted file mode 100644 index a47507d..0000000 --- a/apps/mobile/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -# Dependencies -node_modules/ -package-lock.json - -# Expo -.expo/ -dist/ -expo-env.d.ts - -# Environment -.env -.env.local - -# Build -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision - -# macOS -.DS_Store \ No newline at end of file diff --git a/apps/mobile/README.md b/apps/mobile/README.md deleted file mode 100644 index 82299fc..0000000 --- a/apps/mobile/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# @d3ro/mobile — D3RO Voice Mobile (Expo) - -D3RO Voice의 React Native (Expo) 앱. -**npm workspace에서 제외됨** — Expo의 무거운 native 의존성 때문에 별도 install 필요. - -## 설치 - -```bash -cd apps/mobile -npm install -``` - -## 실행 - -```bash -# Expo dev server 실행 (브라우저에 QR 표시) -npm start - -# iOS 시뮬레이터 -npm run ios - -# Android 에뮬레이터 -npm run android -``` - -## Supabase 설정 - -`app.json`의 `expo.extra`에 환경변수를 추가하거나, EAS Secrets 사용: - -```json -{ - "expo": { - "extra": { - "supabaseUrl": "https://your-project.supabase.co", - "supabaseAnonKey": "eyJ..." - } - } -} -``` - -또는 `app.config.ts`로 변환해서 process.env에서 읽기. - -## 화면 구성 - -| Route | 설명 | -|---|---| -| `/` | 진입점, auth 상태에 따라 리다이렉트 | -| `/login` | OAuth (Google/GitHub) 로그인 | -| `/(tabs)/meetings` | 회의 리스트 (Supabase fetch) | -| `/(tabs)/record` | 녹음 (expo-av) → stt-proxy | -| `/(tabs)/profile` | 사용자 정보 + 로그아웃 | - -## OAuth 콜백 - -`app.json`의 `scheme: "d3ro-voice"`로 deep link 등록. -Supabase Auth providers의 redirect URL에 다음 추가: - -- `d3ro-voice://auth-callback` -- `https://auth.expo.io/@your-username/d3ro-voice` (Expo Go 사용 시) - -## 빌드 (EAS) - -```bash -npm install -g eas-cli -eas login -eas build:configure -eas build --platform ios -eas build --platform android -``` - -## 의존성 비고 - -`@d3ro/core`, `@d3ro/i18n`, `@d3ro/api-client` 등 monorepo 패키지는 향후 file: link로 -재사용 예정. 현재 V2-6 MVP는 코드 중복 (lib/supabase.ts) 허용. - -## V2-6 MVP 한계 - -- DS 컴포넌트 (`@d3ro/ui`)는 MUI 기반이라 RN에서 미사용. 별도 `@d3ro/ui-native` 패키지가 V2-6b에서 필요. -- 푸시 알림 미구현 (V2-6b) -- 회의 상세 화면 미구현 (V2-6b) -- 오프라인 캐싱 미구현 (V2-6b) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts deleted file mode 100644 index 3b78602..0000000 --- a/apps/mobile/app.config.ts +++ /dev/null @@ -1,64 +0,0 @@ -// apps/mobile/app.config.ts -// Expo 설정 — 환경변수 관리, 플러그인, 권한 - -import { type ExpoConfig, type ConfigContext } from 'expo/config' - -export default ({ config }: ConfigContext): ExpoConfig => ({ - ...config, - name: 'D3RO Voice', - slug: 'd3ro-voice', - scheme: 'd3ro-voice', - version: '1.1.0', - orientation: 'portrait', - icon: './assets/icon.png', - userInterfaceStyle: 'dark', - splash: { - image: './assets/splash.png', - resizeMode: 'contain', - backgroundColor: '#19191b' - }, - ios: { - supportsTablet: true, - bundleIdentifier: 'com.d3ro.voice', - infoPlist: { - NSMicrophoneUsageDescription: - 'D3RO Voice uses the microphone for voice recognition and recording.', - UIBackgroundModes: ['audio', 'fetch', 'remote-notification'] - } - }, - android: { - package: 'com.d3ro.voice', - permissions: [ - 'RECORD_AUDIO', - 'FOREGROUND_SERVICE', - 'FOREGROUND_SERVICE_MICROPHONE', - 'POST_NOTIFICATIONS' - ], - adaptiveIcon: { - foregroundImage: './assets/adaptive-icon.png', - backgroundColor: '#19191b' - } - }, - plugins: [ - 'expo-router', - 'expo-secure-store', - 'expo-av', - [ - 'expo-notifications', - { - icon: './assets/icon.png', - color: '#ff5c35' - } - ] - ], - experiments: { - typedRoutes: true - }, - extra: { - supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL ?? '', - supabaseAnonKey: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '', - eas: { - projectId: process.env.EAS_PROJECT_ID ?? '' - } - } -}) diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx deleted file mode 100644 index 88e884f..0000000 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,203 +0,0 @@ -// apps/mobile/app/(tabs)/_layout.tsx -// 5탭 네비게이션: DASH / HIST / REC(FAB) / TALK / SET - -import { useEffect } from 'react' -import { View, StyleSheet, Pressable, Platform } from 'react-native' -import { Tabs, useRouter } from 'expo-router' -import { useAuth } from '../../lib/auth-context' -import { d3roNativePalette } from '@d3ro/ui-native' - -const MONO_FONT = Platform.OS === 'ios' ? 'Menlo' : 'monospace' - -function RecordFAB({ onPress }: { onPress: () => void }): React.ReactElement { - return ( - - - - - - - - - ) -} - -export default function TabsLayout(): React.ReactElement { - const router = useRouter() - const { user, loading } = useAuth() - - useEffect(() => { - if (!loading && !user) { - router.replace('/login') - } - }, [user, loading, router]) - - return ( - - - }} - /> - - }} - /> - router.push('/(tabs)/record')} />, - tabBarLabel: () => null - }} - /> - - }} - /> - - }} - /> - - ) -} - -// 단순 아이콘 대용 (Phase M-2에서 SVG 아이콘으로 교체) -function TabIcon({ type, color }: { type: string; color: string }): React.ReactElement { - const iconStyles: Record = { - dash: ( - - {[0, 1, 2, 3].map((i) => ( - - ))} - - ), - hist: ( - - - - ), - talk: ( - - ), - set: ( - - ) - } - return iconStyles[type] ?? -} - -const styles = StyleSheet.create({ - fab: { - position: 'relative', - top: -20, - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: d3roNativePalette.accent.main, - justifyContent: 'center', - alignItems: 'center', - shadowColor: d3roNativePalette.accent.main, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.4, - shadowRadius: 10, - elevation: 8, - borderWidth: 4, - borderColor: d3roNativePalette.bg.app - }, - fabInner: { - justifyContent: 'center', - alignItems: 'center' - }, - micIcon: { - alignItems: 'center' - }, - micBody: { - width: 8, - height: 14, - borderRadius: 4, - backgroundColor: d3roNativePalette.bg.app, - marginBottom: 2 - }, - micBase: { - width: 14, - height: 2, - borderRadius: 1, - backgroundColor: d3roNativePalette.bg.app - }, - // Tab icons (placeholder — Phase M-2에서 SVG로 교체) - iconGrid: { - width: 22, - height: 22, - flexDirection: 'row', - flexWrap: 'wrap', - gap: 2 - }, - iconGridCell: { - width: 9, - height: 9, - borderWidth: 1.5, - borderRadius: 2 - }, - iconCircle: { - width: 22, - height: 22, - borderRadius: 11, - borderWidth: 1.5, - justifyContent: 'center', - alignItems: 'center' - }, - iconClockHand: { - width: 1.5, - height: 7, - position: 'absolute', - top: 3 - }, - iconChat: { - width: 22, - height: 18, - borderWidth: 1.5, - borderRadius: 4 - }, - iconGear: { - width: 22, - height: 22, - borderWidth: 1.5, - borderRadius: 11 - } -}) diff --git a/apps/mobile/app/(tabs)/dash.tsx b/apps/mobile/app/(tabs)/dash.tsx deleted file mode 100644 index a03d134..0000000 --- a/apps/mobile/app/(tabs)/dash.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// apps/mobile/app/(tabs)/dash.tsx -// Dashboard tab — session stats, usage, system status -// Design ref: docs/v3/designs/dashboard.html - -import { View, ScrollView, StyleSheet } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { - MetalCard, - PhosphorText, - Led, - ScreenPanel, - AppStatusBar, - Header, - d3roNativePalette -} from '@d3ro/ui-native' -import { useI18n } from '@d3ro/i18n' - -export default function DashScreen(): React.ReactElement { - const insets = useSafeAreaInsets() - const { t } = useI18n() - - return ( - -
- - {/* Session Overview — InsetPanel */} - - - {t('mobile.dash.sessionOverview')} - - - 0 - - {t('mobile.dash.today')} - - - - {t('mobile.dash.tapToRecord')} - - - - {t('mobile.dash.words')} - 0 - - - {t('mobile.dash.streak')} - 0 - - - - - {/* 2x2 Stats Grid */} - - - {t('mobile.dash.rec')} - 0 - {t('mobile.dash.min')} - - - {t('mobile.dash.words')} - 0 - - - {t('mobile.dash.today')} - 0 - - - {t('mobile.dash.streak')} - 0 - - - - {/* Backend Info */} - - {t('mobile.dash.backend')} - - - - CLOUD (CLAUDE) - - - - - - {t('mobile.dash.tier')} - - - - {t('mobile.dash.free')} - - - - - {/* Usage */} - - {t('mobile.dash.usage')} - - - - - - - - - - - - ) -} - -function UsageRow({ - label, - value, - progress -}: { - label: string - value: string - progress?: number -}): React.ReactElement { - return ( - - {label} - - {value} - {progress != null && ( - - - - )} - - - ) -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, - content: { paddingHorizontal: 20 }, - overviewPanel: { marginBottom: 12 }, - sectionLabel: { marginBottom: 16 }, - bigNumber: { flexDirection: 'row', alignItems: 'baseline', marginBottom: 8 }, - todayLabel: { marginLeft: 12 }, - guideText: { marginBottom: 20 }, - overviewFooter: { - flexDirection: 'row', - justifyContent: 'space-between', - borderTopWidth: 1, - borderTopColor: d3roNativePalette.border.subtle, - paddingTop: 12 - }, - alignEnd: { alignItems: 'flex-end' }, - statsGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 12, - marginBottom: 12 - }, - statCard: { - width: '47%' as unknown as number, - alignItems: 'center', - paddingVertical: 16, - gap: 4 - }, - infoCard: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 12, - paddingVertical: 14 - }, - infoRow: { flexDirection: 'row', alignItems: 'center' }, - infoValue: { marginLeft: 8 }, - usageTitle: { letterSpacing: 3, marginBottom: 8, marginLeft: 4 }, - usageCard: { marginBottom: 12, padding: 0, overflow: 'hidden' }, - usageRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 16 - }, - usageRight: { alignItems: 'flex-end', gap: 4 }, - usageDivider: { height: 1, backgroundColor: d3roNativePalette.border.subtle }, - progressBar: { - width: 80, - height: 4, - backgroundColor: d3roNativePalette.bg.inset, - borderRadius: 2, - overflow: 'hidden' - }, - progressFill: { - height: '100%' as unknown as number, - backgroundColor: d3roNativePalette.accent.green, - borderRadius: 2 - } -}) diff --git a/apps/mobile/app/(tabs)/history.tsx b/apps/mobile/app/(tabs)/history.tsx deleted file mode 100644 index bf55046..0000000 --- a/apps/mobile/app/(tabs)/history.tsx +++ /dev/null @@ -1,257 +0,0 @@ -// apps/mobile/app/(tabs)/history.tsx -// History tab — transcription records list -// Design ref: docs/v3/designs/history.html - -import { useEffect, useState, useCallback } from 'react' -import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { - MetalCard, - PhosphorText, - Led, - Header, - FilterChip, - AppStatusBar, - d3roNativePalette -} from '@d3ro/ui-native' -import { useI18n } from '@d3ro/i18n' -import { supabase, isSupabaseConfigured } from '../../lib/supabase' - -interface HistoryEntry { - id: string - original_text: string | null - polished_text: string | null - mode: string - status: string - created_at: string - stt_model: string | null - word_count: number | null -} - -type FilterType = 'all' | 'favorites' | 'processing' - -export default function HistoryScreen(): React.ReactElement { - const insets = useSafeAreaInsets() - const { t, formatTime, formatRelativeDate } = useI18n() - const [entries, setEntries] = useState([]) - const [loading, setLoading] = useState(true) - const [refreshing, setRefreshing] = useState(false) - const [filter, setFilter] = useState('all') - - const load = useCallback(async (): Promise => { - if (!isSupabaseConfigured()) { - setLoading(false) - return - } - try { - const query = supabase - .from('history') - .select('id, original_text, polished_text, mode, status, created_at, stt_model, word_count') - .order('created_at', { ascending: false }) - .limit(50) - - const { data, error } = await query - if (!error && data) { - setEntries(data as HistoryEntry[]) - } - } finally { - setLoading(false) - setRefreshing(false) - } - }, []) - - useEffect(() => { - void load() - }, [load]) - - // Group entries by date - const grouped = groupByDate(entries, formatRelativeDate) - - if (loading) { - return ( - - - - ) - } - - return ( - -
} - paddingTop={insets.top} - /> - - {/* Filter Chips */} - - setFilter('all')} - /> - setFilter('favorites')} - /> - setFilter('processing')} - /> - - - item.id} - contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent} - refreshControl={ - { setRefreshing(true); void load() }} - tintColor={d3roNativePalette.accent.main} - /> - } - ListEmptyComponent={ - - {t('mobile.hist.empty')} - - } - renderItem={({ item }) => { - if (item.type === 'header') { - return ( - - {item.label} - - ) - } - const entry = item.entry - const isOld = !isToday(entry.created_at) - return ( - - - - - - - {formatTime(new Date(entry.created_at).getTime())} - - - {entry.word_count != null && ( - - - {entry.word_count} W - - - )} - - - {entry.polished_text ?? entry.original_text ?? '(empty)'} - - - - {entry.stt_model?.toUpperCase() ?? 'LOCAL'} - - - - - ) - }} - ListFooterComponent={} - /> - - ) -} - -// Helpers - -interface GroupedItem { - id: string - type: 'header' | 'entry' - label?: string - entry: HistoryEntry -} - -function isToday(dateStr: string): boolean { - const d = new Date(dateStr) - const now = new Date() - return d.toDateString() === now.toDateString() -} - -function groupByDate( - entries: HistoryEntry[], - formatRelativeDate: (ts: number) => string -): GroupedItem[] { - const result: GroupedItem[] = [] - let lastDate = '' - - for (const entry of entries) { - const dateLabel = formatRelativeDate(new Date(entry.created_at).getTime()) - if (dateLabel !== lastDate) { - result.push({ - id: `header-${dateLabel}`, - type: 'header', - label: dateLabel, - entry - }) - lastDate = dateLabel - } - result.push({ id: entry.id, type: 'entry', entry }) - } - - return result -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, - center: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 32, - backgroundColor: d3roNativePalette.bg.app - }, - filters: { - flexDirection: 'row', - paddingHorizontal: 20, - paddingVertical: 12, - gap: 12, - borderBottomWidth: 1, - borderBottomColor: d3roNativePalette.border.subtle - }, - listContent: { padding: 20, paddingBottom: 120 }, - emptyText: { textAlign: 'center' }, - dateHeader: { - letterSpacing: 3, - marginBottom: 8, - marginTop: 16 - }, - card: { marginBottom: 12 }, - cardOld: { opacity: 0.7 }, - cardHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8 - }, - cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' }, - timeLabel: { marginLeft: 8 }, - wordBadge: { - backgroundColor: d3roNativePalette.accent.dim, - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 4 - }, - transcriptText: { marginBottom: 8 }, - cardMeta: { flexDirection: 'row', gap: 12 } -}) diff --git a/apps/mobile/app/(tabs)/record.tsx b/apps/mobile/app/(tabs)/record.tsx deleted file mode 100644 index 322bed5..0000000 --- a/apps/mobile/app/(tabs)/record.tsx +++ /dev/null @@ -1,287 +0,0 @@ -// apps/mobile/app/(tabs)/record.tsx -// Recording tab — expo-av + STT pipeline -// Design ref: docs/v3/designs/recording.html - -import { useState, useRef } from 'react' -import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { Audio } from 'expo-av' -import Constants from 'expo-constants' -import { - MetalCard, - PhosphorText, - PhysicalButton, - Led, - Header, - WaveBars, - AppStatusBar, - d3roNativePalette, - d3roNativeFonts -} from '@d3ro/ui-native' -import { useI18n } from '@d3ro/i18n' -import { supabase, isSupabaseConfigured } from '../../lib/supabase' - -type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error' - -export default function RecordScreen(): React.ReactElement { - const insets = useSafeAreaInsets() - const { t } = useI18n() - const [state, setState] = useState('idle') - const [transcript, setTranscript] = useState('') - const [error, setError] = useState(null) - const [duration, setDuration] = useState(0) - const recordingRef = useRef(null) - const timerRef = useRef | null>(null) - - async function startRecording(): Promise { - setError(null) - setTranscript('') - setDuration(0) - - try { - const perm = await Audio.requestPermissionsAsync() - if (perm.status !== 'granted') { - setError(t('mobile.rec.micDenied')) - setState('error') - return - } - - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true - }) - - const recording = new Audio.Recording() - await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY) - await recording.startAsync() - recordingRef.current = recording - setState('recording') - - timerRef.current = setInterval(() => { - setDuration((d) => d + 1) - }, 1000) - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to start') - setState('error') - } - } - - async function stopRecording(): Promise { - if (!recordingRef.current) return - if (timerRef.current) { - clearInterval(timerRef.current) - timerRef.current = null - } - setState('processing') - - try { - await recordingRef.current.stopAndUnloadAsync() - const uri = recordingRef.current.getURI() - recordingRef.current = null - - if (!uri) throw new Error('No recording URI') - - if (!isSupabaseConfigured()) { - setError(t('mobile.rec.supabaseNotConfigured')) - setState('error') - return - } - - const { data: { session } } = await supabase.auth.getSession() - if (!session) { - setError(t('mobile.rec.loginRequired')) - setState('error') - return - } - - const formData = new FormData() - formData.append('audio', { - uri, - name: 'recording.m4a', - type: 'audio/m4a' - } as unknown as Blob) - formData.append('language_code', 'ko-KR') - - const url = (Constants.expoConfig?.extra?.supabaseUrl as string) ?? '' - const response = await fetch(`${url}/functions/v1/stt-proxy`, { - method: 'POST', - headers: { Authorization: `Bearer ${session.access_token}` }, - body: formData - }) - - if (!response.ok) throw new Error(`STT failed: ${response.status}`) - - const result = (await response.json()) as { transcript: string } - setTranscript(result.transcript) - setState('done') - } catch (e) { - setError(e instanceof Error ? e.message : 'Unknown error') - setState('error') - } - } - - async function cancelRecording(): Promise { - if (timerRef.current) { - clearInterval(timerRef.current) - timerRef.current = null - } - if (recordingRef.current) { - try { await recordingRef.current.stopAndUnloadAsync() } catch { /* ignore */ } - recordingRef.current = null - } - setState('idle') - setTranscript('') - setError(null) - setDuration(0) - } - - const formatTime = (s: number): string => { - const m = Math.floor(s / 60) - const sec = s % 60 - return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` - } - - const statusLabel = - state === 'recording' ? t('mobile.rec.recording') - : state === 'processing' ? t('mobile.rec.processing') - : state === 'done' ? t('mobile.rec.done') - : state === 'error' ? t('mobile.rec.error') - : t('mobile.rec.ready') - - return ( - -
- - {formatTime(duration)} - - - - ) : undefined - } - /> - - {/* Wave Bars */} - - - {/* Transcript Area */} - - {state === 'idle' && ( - - {t('mobile.rec.initLog')} - - )} - - {state === 'processing' && ( - - )} - - {transcript !== '' && state === 'done' && ( - - - {transcript} - - - )} - - {error !== null && ( - - - {error} - - - )} - - - {/* Status Line */} - {state === 'recording' && ( - - - - {t('mobile.rec.listening')} - - - {t('mobile.rec.realtime')} - - - )} - - {/* Buttons */} - - {(state === 'idle' || state === 'done' || state === 'error') && ( - void startRecording()} - /> - )} - {state === 'recording' && ( - <> - void stopRecording()} - /> - - void cancelRecording()} - /> - - )} - - - - - ) -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, - content: { paddingBottom: 100 }, - headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - transcriptArea: { - minHeight: 160, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 24 - }, - initLog: { - fontFamily: d3roNativeFonts.mono, - textAlign: 'center' - }, - transcriptBox: { - backgroundColor: d3roNativePalette.bg.inset, - padding: 12, - borderRadius: 8, - width: '100%' - }, - errorBox: { - borderWidth: 1, - borderColor: d3roNativePalette.accent.main, - backgroundColor: d3roNativePalette.accent.dim, - padding: 10, - borderRadius: 6, - width: '100%' - }, - listeningRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - paddingBottom: 16 - }, - listeningText: { letterSpacing: 2 }, - buttons: { paddingHorizontal: 20, paddingBottom: 16 }, - buttonSpacer: { height: 12 } -}) diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx deleted file mode 100644 index 9e4119e..0000000 --- a/apps/mobile/app/(tabs)/settings.tsx +++ /dev/null @@ -1,209 +0,0 @@ -// apps/mobile/app/(tabs)/settings.tsx -// Settings tab — account, backend, preferences -// Design ref: docs/v3/designs/settings.html - -import { View, ScrollView, StyleSheet, Alert, Switch } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { - MetalCard, - PhosphorText, - PhysicalButton, - Led, - AppStatusBar, - d3roNativePalette -} from '@d3ro/ui-native' -import { useI18n } from '@d3ro/i18n' -import { useAuth } from '../../lib/auth-context' -import { supabase } from '../../lib/supabase' - -export default function SettingsScreen(): React.ReactElement { - const insets = useSafeAreaInsets() - const { t } = useI18n() - const { user } = useAuth() - - async function handleLogout(): Promise { - Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [ - { text: t('mobile.rec.cancel'), style: 'cancel' }, - { - text: t('mobile.set.logout'), - style: 'destructive', - onPress: async () => { - await supabase.auth.signOut() - } - } - ]) - } - - const initials = user?.email - ? user.email.substring(0, 2).toUpperCase() - : 'US' - - return ( - - - {t('mobile.set.title')} - - - {/* Account Section */} - - {t('mobile.set.accountPlan')} - - - - - {initials} - - - - {user?.email?.split('@')[0] ?? 'User'} - - - {user?.email ?? '\u2014'} - - - - - {t('mobile.dash.tier')} - - - - {t('mobile.dash.free')} - - - - - - {/* Backend Section */} - - {t('mobile.set.backendConfig')} - - - - - {t('mobile.set.llmModel')} - {t('mobile.set.llmDesc')} - - CLAUDE - - - - {t('mobile.set.cloudStt')} - {t('mobile.set.cloudSttDesc')} - - - - - - {/* Preferences Section */} - - {t('mobile.set.preferences')} - - - - {t('mobile.set.language')} - Korean - - - {t('mobile.set.autoPolish')} - - - - {t('mobile.set.haptic')} - - - - - {/* Logout */} - - void handleLogout()} - /> - - - {/* Status Footer */} - - - ) -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, - content: { paddingBottom: 120 }, - title: { - paddingHorizontal: 20, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: d3roNativePalette.border.subtle - }, - sectionLabel: { - paddingHorizontal: 24, - paddingTop: 20, - paddingBottom: 8, - letterSpacing: 3 - }, - section: { - marginHorizontal: 20, - padding: 0, - overflow: 'hidden' - }, - profileRow: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - gap: 12, - borderBottomWidth: 1, - borderBottomColor: d3roNativePalette.border.subtle - }, - avatar: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: d3roNativePalette.bg.inset, - borderWidth: 1, - borderColor: d3roNativePalette.border.default, - justifyContent: 'center', - alignItems: 'center' - }, - profileInfo: { gap: 2 }, - row: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 16 - }, - rowInset: { backgroundColor: d3roNativePalette.bg.inset }, - rowBorder: { - borderTopWidth: 1, - borderTopColor: d3roNativePalette.border.subtle - }, - rowRight: { flexDirection: 'row', alignItems: 'center' }, - rowValue: { marginLeft: 8 }, - logoutWrap: { margin: 20 } -}) diff --git a/apps/mobile/app/(tabs)/talk.tsx b/apps/mobile/app/(tabs)/talk.tsx deleted file mode 100644 index d070108..0000000 --- a/apps/mobile/app/(tabs)/talk.tsx +++ /dev/null @@ -1,224 +0,0 @@ -// apps/mobile/app/(tabs)/talk.tsx -// AI Chat tab — text+voice chat -// Design ref: docs/v3/designs/talk.html - -import { useState, useRef } from 'react' -import { - View, - ScrollView, - TextInput, - StyleSheet, - Pressable, - KeyboardAvoidingView, - Platform -} from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { - PhosphorText, - Led, - Header, - AppStatusBar, - d3roNativePalette, - d3roNativeFonts -} from '@d3ro/ui-native' -import { useI18n } from '@d3ro/i18n' - -interface ChatMessage { - id: string - role: 'user' | 'assistant' - content: string -} - -export default function TalkScreen(): React.ReactElement { - const insets = useSafeAreaInsets() - const { t } = useI18n() - const scrollRef = useRef(null) - const [messages, setMessages] = useState([ - { - id: '1', - role: 'assistant', - content: t('mobile.talk.greeting') - } - ]) - const [input, setInput] = useState('') - - function handleSend(): void { - if (!input.trim()) return - const userMsg: ChatMessage = { - id: String(Date.now()), - role: 'user', - content: input.trim() - } - setMessages((prev) => [...prev, userMsg]) - setInput('') - // Phase M-5: actual AI response - setTimeout(() => { - scrollRef.current?.scrollToEnd({ animated: true }) - }, 100) - } - - return ( - -
- {t('mobile.talk.live')} - - - } - /> - - {/* Chat Messages */} - - {/* Date Badge */} - - - {new Date().toLocaleDateString(undefined, { - month: 'short', - day: 'numeric' - }).toUpperCase()} - - - - {messages.map((msg) => ( - - - {msg.content} - - - {msg.role === 'user' ? t('mobile.talk.you') : t('mobile.talk.claude')} - - - ))} - - - {/* Input Area */} - - - - - - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, - headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - chatArea: { flex: 1 }, - chatContent: { padding: 20, paddingBottom: 20, gap: 16 }, - dateBadge: { - alignSelf: 'center', - backgroundColor: d3roNativePalette.bg.card, - borderWidth: 1, - borderColor: d3roNativePalette.border.default, - borderRadius: 999, - paddingHorizontal: 12, - paddingVertical: 4, - marginBottom: 8 - }, - bubble: { - maxWidth: '85%', - padding: 16, - borderRadius: 16, - marginBottom: 20 - }, - aiBubble: { - alignSelf: 'flex-start', - backgroundColor: d3roNativePalette.bg.card, - borderWidth: 1, - borderColor: d3roNativePalette.border.default, - borderTopLeftRadius: 4 - }, - userBubble: { - alignSelf: 'flex-end', - backgroundColor: d3roNativePalette.accent.dim, - borderWidth: 1, - borderColor: 'rgba(255, 92, 53, 0.3)', - borderTopRightRadius: 4 - }, - bubbleText: { fontFamily: d3roNativeFonts.sans }, - bubbleLabel: { - position: 'absolute', - bottom: -16, - fontSize: 9, - letterSpacing: 1 - }, - inputArea: { - backgroundColor: 'rgba(25, 25, 27, 0.95)', - borderTopWidth: 1, - borderTopColor: d3roNativePalette.border.default, - paddingHorizontal: 12, - paddingTop: 12, - paddingBottom: 8 - }, - inputRow: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: d3roNativePalette.bg.inset, - borderRadius: 12, - borderWidth: 1, - borderColor: d3roNativePalette.border.default, - padding: 8 - }, - textInput: { - flex: 1, - color: d3roNativePalette.text.primary, - fontSize: 14, - paddingHorizontal: 8, - fontFamily: d3roNativeFonts.sans - }, - sendBtn: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: d3roNativePalette.accent.dim, - borderWidth: 1, - borderColor: 'rgba(255, 92, 53, 0.3)', - justifyContent: 'center', - alignItems: 'center' - }, - sendArrow: { - width: 0, - height: 0, - borderLeftWidth: 5, - borderRightWidth: 5, - borderBottomWidth: 8, - borderLeftColor: 'transparent', - borderRightColor: 'transparent', - borderBottomColor: d3roNativePalette.accent.main - }, - inputStatus: { paddingVertical: 4 } -}) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx deleted file mode 100644 index 5eb0fd3..0000000 --- a/apps/mobile/app/_layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// apps/mobile/app/_layout.tsx -// Root layout — Stack + AuthProvider + I18nProvider - -import { Stack } from 'expo-router' -import { StatusBar } from 'expo-status-bar' -import { GestureHandlerRootView } from 'react-native-gesture-handler' -import { SafeAreaProvider } from 'react-native-safe-area-context' -import { AuthProvider } from '../lib/auth-context' -import { I18nProvider } from '@d3ro/i18n' -import { d3roNativePalette } from '@d3ro/ui-native' - -export default function RootLayout(): React.ReactElement { - return ( - - - - - - - - - - - - - - - ) -} diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx deleted file mode 100644 index 00eabaa..0000000 --- a/apps/mobile/app/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// apps/mobile/app/index.tsx -// 진입점 — 로그인 상태에 따라 리다이렉트 - -import { useEffect } from 'react' -import { ActivityIndicator, View } from 'react-native' -import { useRouter } from 'expo-router' -import { useAuth } from '../lib/auth-context' - -export default function Index(): React.ReactElement { - const router = useRouter() - const { user, loading } = useAuth() - - useEffect(() => { - if (!loading) { - if (user) { - router.replace('/(tabs)/dash') - } else { - router.replace('/login') - } - } - }, [user, loading, router]) - - return ( - - - - ) -} diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx deleted file mode 100644 index 43f5a7b..0000000 --- a/apps/mobile/app/login.tsx +++ /dev/null @@ -1,315 +0,0 @@ -// apps/mobile/app/login.tsx -// Login screen — D3RO styled -// Design ref: docs/v3/designs/login.html - -import { useState } from 'react' -import { View, TextInput, StyleSheet, Alert, ScrollView, Pressable, Platform } from 'react-native' -import * as WebBrowser from 'expo-web-browser' -import * as Linking from 'expo-linking' -import { - d3roNativePalette, - d3roNativeFonts, - Led, - PhosphorText -} from '@d3ro/ui-native' -import { useI18n } from '@d3ro/i18n' -import { supabase, isSupabaseConfigured } from '../lib/supabase' -import { useAuth } from '../lib/auth-context' -import { useRouter } from 'expo-router' - -WebBrowser.maybeCompleteAuthSession() - -export default function LoginScreen(): React.ReactElement { - const { t } = useI18n() - const [busy, setBusy] = useState(false) - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const configured = isSupabaseConfigured() - const { devBypass } = useAuth() - const router = useRouter() - - function handleDevSkip(): void { - devBypass() - router.replace('/(tabs)/dash') - } - - async function signInWithProvider(provider: 'google' | 'github' | 'apple'): Promise { - if (!configured) { - Alert.alert('Not Configured', t('mobile.login.notConfigured')) - return - } - setBusy(true) - try { - const redirectTo = Linking.createURL('auth-callback') - const { data, error } = await supabase.auth.signInWithOAuth({ - provider, - options: { redirectTo, skipBrowserRedirect: true } - }) - - if (error || !data.url) { - Alert.alert('Login Failed', error?.message ?? 'Could not get OAuth URL') - return - } - - const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo) - if (result.type === 'success' && result.url) { - const url = new URL(result.url) - const code = url.searchParams.get('code') - if (code) { - const { error: exchangeErr } = await supabase.auth.exchangeCodeForSession(code) - if (exchangeErr) { - Alert.alert('Session Error', exchangeErr.message) - } - } - } - } finally { - setBusy(false) - } - } - - async function signInWithEmail(): Promise { - if (!configured) { - Alert.alert('Not Configured', t('mobile.login.notConfigured')) - return - } - if (!email.trim() || !password.trim()) return - setBusy(true) - try { - const { error } = await supabase.auth.signInWithPassword({ email, password }) - if (error) { - Alert.alert('Login Failed', error.message) - } - } finally { - setBusy(false) - } - } - - return ( - - {/* Logo & Branding */} - - - - - - - {t('mobile.login.title')} - - - {t('mobile.login.subtitle')} - - - - {/* OAuth Buttons */} - - [styles.oauthBtn, pressed && styles.oauthBtnPressed]} - onPress={() => void signInWithProvider('google')} - disabled={busy} - > - G - - {t('mobile.login.google')} - - - - [styles.oauthBtn, pressed && styles.oauthBtnPressed]} - onPress={() => void signInWithProvider('apple')} - disabled={busy} - > - - {'\uF8FF'} - - - {t('mobile.login.apple')} - - - - [styles.oauthBtn, pressed && styles.oauthBtnPressed]} - onPress={() => void signInWithProvider('github')} - disabled={busy} - > - {'\u2318'} - - {t('mobile.login.github')} - - - - - {/* Divider */} - - - - {t('mobile.login.or')} - - - - - {/* Email/Password Fields */} - - - - {t('mobile.login.email')} - - - - - - {t('mobile.login.password')} - - - - - - {/* Login Button */} - [styles.loginBtn, pressed && { opacity: 0.9 }]} - onPress={() => void signInWithEmail()} - disabled={busy} - > - - {t('mobile.login.signIn')} - - - - {/* Sign Up Link */} - - - {t('mobile.login.noAccount')} - - - - {t('mobile.login.signUp')} - - - - - {/* DEV Skip */} - {__DEV__ && ( - - - {'\u26A1'} DEV SKIP LOGIN - - - )} - - {/* Supabase Warning */} - {!configured && ( - - - {'\u26A0'} {t('mobile.login.notConfigured')} - - - )} - - ) -} - -const P = d3roNativePalette - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: P.bg.app }, - content: { - flexGrow: 1, - justifyContent: 'center', - paddingHorizontal: 24, - paddingBottom: 40, - paddingTop: 60 - }, - branding: { alignItems: 'center', marginBottom: 40 }, - leds: { flexDirection: 'row', gap: 10, marginBottom: 20 }, - titleText: { letterSpacing: 5 }, - subtitleText: { marginTop: 8, letterSpacing: 2 }, - oauthSection: { gap: 12, marginBottom: 24 }, - oauthBtn: { - backgroundColor: P.bg.card, - borderWidth: 1, - borderColor: P.border.default, - borderRadius: 12, - paddingVertical: 14, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 12 - }, - oauthBtnPressed: { backgroundColor: P.bg.cardHover, borderColor: 'rgba(113,113,122,0.4)' }, - oauthLabel: { fontFamily: d3roNativeFonts.sans }, - appleIcon: { fontSize: 18 }, - divider: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 24 }, - dividerLine: { flex: 1, height: 1, backgroundColor: P.border.default }, - dividerText: { letterSpacing: 2 }, - formSection: { gap: 16, marginBottom: 24 }, - fieldLabel: { letterSpacing: 4, marginBottom: 6, marginLeft: 4 }, - input: { - backgroundColor: P.bg.inset, - borderWidth: 1, - borderColor: P.border.default, - borderRadius: 12, - paddingVertical: 14, - paddingHorizontal: 16, - fontSize: 14, - color: P.text.primary, - fontFamily: Platform.OS === 'ios' ? 'System' : 'Roboto' - }, - loginBtn: { - backgroundColor: P.accent.main, - borderRadius: 12, - paddingVertical: 14, - alignItems: 'center', - shadowColor: P.accent.main, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.3, - shadowRadius: 10, - elevation: 6, - marginBottom: 24 - }, - loginBtnText: { color: P.bg.app, letterSpacing: 1 }, - signupRow: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 6, - marginBottom: 16 - }, - warningBox: { - borderWidth: 1, - borderColor: 'rgba(255,92,53,0.3)', - backgroundColor: P.accent.dim, - padding: 12, - borderRadius: 8, - marginTop: 8 - }, - warningText: { textAlign: 'center' }, - devSkipBtn: { - borderWidth: 1, - borderColor: P.accent.green, - borderRadius: 8, - paddingVertical: 10, - alignItems: 'center', - marginBottom: 12, - backgroundColor: 'rgba(74, 222, 128, 0.08)' - }, - devSkipText: { color: P.accent.green, letterSpacing: 2 } -}) diff --git a/apps/mobile/lib/auth-context.tsx b/apps/mobile/lib/auth-context.tsx deleted file mode 100644 index d457917..0000000 --- a/apps/mobile/lib/auth-context.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// apps/mobile/lib/auth-context.tsx -// Supabase 세션 Context + 로그인 시 Expo Push 토큰 자동 등록 - -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import type { Session, User } from '@supabase/supabase-js' -import { supabase } from './supabase' -import { registerPushToken } from './push' - -interface AuthContextValue { - session: Session | null - user: User | null - loading: boolean - devBypass: () => void -} - -const AuthContext = createContext({ - session: null, - user: null, - loading: true, - devBypass: () => {} -}) - -// DEV 전용 — 가짜 유저로 로그인 우회 -const DEV_USER: User = { - id: 'dev-user-00000', - email: 'dev@d3ro.local', - app_metadata: {}, - user_metadata: {}, - aud: 'authenticated', - created_at: new Date().toISOString() -} as User - -export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement { - const [session, setSession] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - supabase.auth - .getSession() - .then(({ data }) => { - setSession(data.session) - if (data.session?.user.id) { - void registerPushToken(data.session.user.id) - } - }) - .finally(() => setLoading(false)) - - const { - data: { subscription } - } = supabase.auth.onAuthStateChange((_event, newSession) => { - setSession(newSession) - if (newSession?.user.id) { - void registerPushToken(newSession.user.id) - } - }) - - return () => { - subscription.unsubscribe() - } - }, []) - - const [devMode, setDevMode] = useState(false) - - function devBypass(): void { - setDevMode(true) - } - - const effectiveUser = devMode ? DEV_USER : (session?.user ?? null) - - return ( - - {children} - - ) -} - -export function useAuth(): AuthContextValue { - return useContext(AuthContext) -} diff --git a/apps/mobile/lib/push.ts b/apps/mobile/lib/push.ts deleted file mode 100644 index 3327daf..0000000 --- a/apps/mobile/lib/push.ts +++ /dev/null @@ -1,77 +0,0 @@ -// apps/mobile/lib/push.ts -// Expo Push token 등록 — 앱 진입 시 호출하면 Supabase push_tokens 테이블에 upsert. - -import * as Notifications from 'expo-notifications' -import * as Device from 'expo-device' -import { Platform } from 'react-native' -import Constants from 'expo-constants' -import { supabase } from './supabase' - -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: false, - shouldShowBanner: true, - shouldShowList: true - }) -}) - -/** - * Expo push token을 받아 Supabase push_tokens 테이블에 upsert. - * 시뮬레이터/에뮬레이터에서는 token이 발급되지 않으므로 조용히 return. - */ -export async function registerPushToken(userId: string): Promise { - if (!Device.isDevice) { - return - } - - try { - // 권한 요청 - const { status: existingStatus } = await Notifications.getPermissionsAsync() - let finalStatus = existingStatus - if (existingStatus !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync() - finalStatus = status - } - if (finalStatus !== 'granted') { - return - } - - // EAS projectId는 app.json/eas.json에서 - const projectId = - (Constants.expoConfig?.extra?.eas?.projectId as string | undefined) ?? - (Constants.easConfig?.projectId as string | undefined) - - const tokenData = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined) - const token = tokenData.data - - if (!token) return - - const platform: 'ios' | 'android' | 'web' = - Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web' - - // Android 채널 설정 - if (Platform.OS === 'android') { - await Notifications.setNotificationChannelAsync('default', { - name: 'default', - importance: Notifications.AndroidImportance.MAX, - vibrationPattern: [0, 250, 250, 250], - lightColor: '#f25b29' - }) - } - - // Supabase upsert - await supabase.from('push_tokens').upsert( - { - user_id: userId, - token, - platform, - device_name: Device.deviceName ?? null - }, - { onConflict: 'token' } - ) - } catch { - // push 등록 실패는 앱 진행 차단하지 않음 - } -} diff --git a/apps/mobile/lib/supabase.ts b/apps/mobile/lib/supabase.ts deleted file mode 100644 index ae621e3..0000000 --- a/apps/mobile/lib/supabase.ts +++ /dev/null @@ -1,35 +0,0 @@ -// apps/mobile/lib/supabase.ts -// React Native용 Supabase 클라이언트 — AsyncStorage 어댑터 + URL polyfill - -import 'react-native-url-polyfill/auto' -import AsyncStorage from '@react-native-async-storage/async-storage' -import { createClient, type SupabaseClient } from '@supabase/supabase-js' -import Constants from 'expo-constants' - -const supabaseUrl = (Constants.expoConfig?.extra?.supabaseUrl as string | undefined) ?? '' -const supabaseAnonKey = (Constants.expoConfig?.extra?.supabaseAnonKey as string | undefined) ?? '' - -export function isSupabaseConfigured(): boolean { - return Boolean(supabaseUrl && supabaseAnonKey) -} - -function createSupabaseClient(): SupabaseClient { - if (!isSupabaseConfigured()) { - // env 미설정 시에도 크래시 방지용 더미 클라이언트 생성 - // 런타임에서 isSupabaseConfigured()로 체크 후 사용 - return createClient('https://localhost.invalid', 'no-key', { - auth: { storage: AsyncStorage, persistSession: false, detectSessionInUrl: false } - }) - } - - return createClient(supabaseUrl, supabaseAnonKey, { - auth: { - storage: AsyncStorage, - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false - } - }) -} - -export const supabase = createSupabaseClient() diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js deleted file mode 100644 index 438f4b1..0000000 --- a/apps/mobile/metro.config.js +++ /dev/null @@ -1,36 +0,0 @@ -// apps/mobile/metro.config.js -// Monorepo 호환 Metro 설정 -// 핵심: root node_modules의 react-native 0.84를 절대 참조하지 않도록 격리 - -const { getDefaultConfig } = require('expo/metro-config') -const path = require('path') - -const projectRoot = __dirname -const monorepoRoot = path.resolve(projectRoot, '../..') - -const config = getDefaultConfig(projectRoot) - -// packages 디렉토리만 watch (root node_modules는 watch하지 않음!) -config.watchFolders = [ - path.resolve(monorepoRoot, 'packages/core'), - path.resolve(monorepoRoot, 'packages/ui-native'), - path.resolve(monorepoRoot, 'packages/i18n'), - path.resolve(monorepoRoot, 'packages/api-client') -] - -// node_modules: 로컬만 사용 -config.resolver.nodeModulesPaths = [ - path.resolve(projectRoot, 'node_modules') -] - -// packages 내에서 import하는 모든 모듈을 로컬 node_modules로 강제 -config.resolver.extraNodeModules = new Proxy( - {}, - { - get: (_target, name) => { - return path.resolve(projectRoot, 'node_modules', String(name)) - } - } -) - -module.exports = config diff --git a/apps/mobile/package.json b/apps/mobile/package.json deleted file mode 100644 index b8910d3..0000000 --- a/apps/mobile/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@d3ro/mobile", - "version": "1.1.0", - "private": true, - "description": "D3RO Voice Mobile (Expo + React Native)", - "main": "expo-router/entry", - "scripts": { - "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@d3ro/api-client": "file:../../packages/api-client", - "@d3ro/core": "file:../../packages/core", - "@d3ro/i18n": "file:../../packages/i18n", - "@d3ro/ui-native": "file:../../packages/ui-native", - "@react-native-async-storage/async-storage": "1.23.1", - "@supabase/supabase-js": "^2.45.0", - "expo": "~51.0.0", - "expo-av": "~14.0.0", - "expo-constants": "~16.0.0", - "expo-device": "~6.0.0", - "expo-linking": "~6.3.0", - "expo-notifications": "~0.28.0", - "expo-router": "~3.5.0", - "expo-secure-store": "~13.0.0", - "expo-status-bar": "~1.12.0", - "expo-web-browser": "~13.0.0", - "react": "18.2.0", - "react-native": "0.74.0", - "react-native-gesture-handler": "~2.16.0", - "react-native-reanimated": "~3.10.0", - "react-native-safe-area-context": "4.10.0", - "react-native-screens": "3.31.0", - "react-native-url-polyfill": "^2.0.0" - }, - "devDependencies": { - "@types/react": "~18.2.0", - "typescript": "^5.7.0" - }, - "overrides": { - "@types/react": "~18.2.0" - } -} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json deleted file mode 100644 index 19d402f..0000000 --- a/apps/mobile/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "expo/tsconfig.base", - "compilerOptions": { - "strict": true, - "noImplicitAny": true, - "esModuleInterop": true, - "moduleResolution": "bundler", - "skipLibCheck": true, - "baseUrl": ".", - "paths": { - "@/*": ["./*"] - }, - "typeRoots": ["./node_modules/@types"] - }, - "include": [ - "app/**/*.ts", - "app/**/*.tsx", - "lib/**/*.ts", - "lib/**/*.tsx", - ".expo/types/**/*.ts", - "expo-env.d.ts" - ] -}