From 94d8bb8ebe81f9c4f02537f1227fd144d060c68f Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 16 Sep 2026 23:24:55 +0900 Subject: [PATCH] feat(mobile): keep team, template, and report flows in sync with the server Team, meeting, memo, template, command, and dictionary screens drifted from the server contract, and report submission could hang instead of confirming to the user. The screens now use the server responses directly. The retired Expo shell is removed; the React Native app is the mobile client. Gradle-generated vector-icon drawables are ignored rather than committed. --- .gitignore | 2 + apps/mobile-rn/__tests__/teams.test.ts | 54 +++ .../src/components/ContentReportSheet.tsx | 2 +- .../src/features/teams/team-service.ts | 127 ++++++- .../src/screens/AdminUserDetailScreen.tsx | 2 +- apps/mobile-rn/src/screens/CommandsScreen.tsx | 2 +- .../src/screens/DictionaryScreen.tsx | 2 +- .../src/screens/MeetingDetailScreen.tsx | 2 +- apps/mobile-rn/src/screens/MeetingsScreen.tsx | 2 +- apps/mobile-rn/src/screens/MemosScreen.tsx | 2 +- .../src/screens/TeamDetailScreen.tsx | 86 ++++- apps/mobile-rn/src/screens/TeamsScreen.tsx | 2 +- .../mobile-rn/src/screens/TemplatesScreen.tsx | 2 +- apps/mobile-rn/src/theme/mobile-theme.ts | 35 ++ apps/mobile/.gitignore | 22 -- apps/mobile/README.md | 81 ----- apps/mobile/app.config.ts | 64 ---- apps/mobile/app/(tabs)/_layout.tsx | 203 ----------- apps/mobile/app/(tabs)/dash.tsx | 197 ----------- apps/mobile/app/(tabs)/history.tsx | 257 -------------- apps/mobile/app/(tabs)/record.tsx | 287 ---------------- apps/mobile/app/(tabs)/settings.tsx | 209 ------------ apps/mobile/app/(tabs)/talk.tsx | 224 ------------- apps/mobile/app/_layout.tsx | 35 -- apps/mobile/app/index.tsx | 28 -- apps/mobile/app/login.tsx | 315 ------------------ apps/mobile/lib/auth-context.tsx | 79 ----- apps/mobile/lib/push.ts | 77 ----- apps/mobile/lib/supabase.ts | 35 -- apps/mobile/metro.config.js | 36 -- apps/mobile/package.json | 45 --- apps/mobile/tsconfig.json | 23 -- 32 files changed, 310 insertions(+), 2229 deletions(-) delete mode 100644 apps/mobile/.gitignore delete mode 100644 apps/mobile/README.md delete mode 100644 apps/mobile/app.config.ts delete mode 100644 apps/mobile/app/(tabs)/_layout.tsx delete mode 100644 apps/mobile/app/(tabs)/dash.tsx delete mode 100644 apps/mobile/app/(tabs)/history.tsx delete mode 100644 apps/mobile/app/(tabs)/record.tsx delete mode 100644 apps/mobile/app/(tabs)/settings.tsx delete mode 100644 apps/mobile/app/(tabs)/talk.tsx delete mode 100644 apps/mobile/app/_layout.tsx delete mode 100644 apps/mobile/app/index.tsx delete mode 100644 apps/mobile/app/login.tsx delete mode 100644 apps/mobile/lib/auth-context.tsx delete mode 100644 apps/mobile/lib/push.ts delete mode 100644 apps/mobile/lib/supabase.ts delete mode 100644 apps/mobile/metro.config.js delete mode 100644 apps/mobile/package.json delete mode 100644 apps/mobile/tsconfig.json 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" - ] -}