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.
This commit is contained in:
Yun Chan 2026-09-16 23:24:55 +09:00
parent bb0e54dcee
commit 94d8bb8ebe
32 changed files with 310 additions and 2229 deletions

2
.gitignore vendored
View file

@ -108,6 +108,8 @@ apps/web/test-results/
apps/web/playwright-report/ apps/web/playwright-report/
apps/desktop/test-results/ 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 CLI local state
/supabase/.branches/ /supabase/.branches/
/supabase/.temp/ /supabase/.temp/

View file

@ -29,11 +29,13 @@ import {
acceptTeamInvite, acceptTeamInvite,
cancelTeamInvite, cancelTeamInvite,
createTeam, createTeam,
createTeamActivity,
createTeamInvite, createTeamInvite,
extractInviteToken, extractInviteToken,
normalizeInviteEmail, normalizeInviteEmail,
normalizeInviteToken, normalizeInviteToken,
normalizeInviteUrl, normalizeInviteUrl,
normalizeTeamActivity,
normalizeTeamInvite, normalizeTeamInvite,
normalizeTeamMember, normalizeTeamMember,
normalizeTeamName, 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', () => { describe('team realtime contract', () => {
test('filters every mutable team table by the selected team', () => { test('filters every mutable team table by the selected team', () => {
mockRealtimeOn.mockClear() 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_members', filter: `team_id=eq.${TEAM_ID}` },
{ event: '*', schema: 'public', table: 'team_invites', 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: 'meetings', filter: `team_id=eq.${TEAM_ID}` },
{ event: '*', schema: 'public', table: 'team_activities', filter: `team_id=eq.${TEAM_ID}` },
]) ])
}) })
}) })

View file

@ -283,7 +283,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
overlay: { overlay: {
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
backgroundColor: 'rgba(0, 0, 0, 0.62)', backgroundColor: palette.scrim,
}, },
keyboardArea: { width: '100%', justifyContent: 'flex-end' }, keyboardArea: { width: '100%', justifyContent: 'flex-end' },
sheet: { sheet: {

View file

@ -38,12 +38,30 @@ export interface TeamAcceptResult {
alreadyMember: boolean 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 { export interface TeamDetail {
team: Team team: Team
currentRole: TeamRole currentRole: TeamRole
members: TeamMemberDirectoryEntry[] members: TeamMemberDirectoryEntry[]
invites: TeamInviteListEntry[] invites: TeamInviteListEntry[]
meetings: Meeting[] meetings: Meeting[]
activities: TeamActivityEntry[]
} }
export type TeamServiceErrorCode = 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 } { function errorDetails(error: unknown): { code: string; message: string } {
const candidate = error as { const candidate = error as {
code?: unknown code?: unknown
@ -441,11 +499,63 @@ async function listTeamInvites(teamId: string): Promise<TeamInviteListEntry[]> {
return data.map(normalizeTeamInvite) return data.map(normalizeTeamInvite)
} }
export async function listTeamActivities(teamId: string, limit = 50): Promise<TeamActivityEntry[]> {
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<TeamActivityEntry> {
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<TeamDetail> { export async function getTeamDetail(userId: string, teamId: string): Promise<TeamDetail> {
requireUuid(userId, 'Authenticated user') requireUuid(userId, 'Authenticated user')
requireUuid(teamId, 'Team') requireUuid(teamId, 'Team')
try { try {
const [teamResult, members, meetingsResult] = await Promise.all([ const [teamResult, members, meetingsResult, activitiesResult] = await Promise.all([
supabase.from('teams').select('*').eq('id', teamId).maybeSingle(), supabase.from('teams').select('*').eq('id', teamId).maybeSingle(),
listTeamMembers(teamId), listTeamMembers(teamId),
supabase supabase
@ -454,12 +564,19 @@ export async function getTeamDetail(userId: string, teamId: string): Promise<Tea
.eq('team_id', teamId) .eq('team_id', teamId)
.order('started_at', { ascending: false }) .order('started_at', { ascending: false })
.limit(20), .limit(20),
supabase
.from('team_activities')
.select('*')
.eq('team_id', teamId)
.order('created_at', { ascending: false })
.limit(50),
]) ])
if (teamResult.error !== null) throw teamResult.error if (teamResult.error !== null) throw teamResult.error
if (teamResult.data === null) { if (teamResult.data === null) {
throw new TeamServiceError('not-found', 'Team was not found') throw new TeamServiceError('not-found', 'Team was not found')
} }
if (meetingsResult.error !== null) throw meetingsResult.error if (meetingsResult.error !== null) throw meetingsResult.error
if (activitiesResult.error !== null) throw activitiesResult.error
const team = normalizeTeam(teamResult.data) const team = normalizeTeam(teamResult.data)
const ownMembership = members.find((member) => member.userId === userId) const ownMembership = members.find((member) => member.userId === userId)
if (ownMembership === undefined) { if (ownMembership === undefined) {
@ -473,7 +590,8 @@ export async function getTeamDetail(userId: string, teamId: string): Promise<Tea
if (meetings.some((meeting) => meeting.team_id !== teamId)) { if (meetings.some((meeting) => meeting.team_id !== teamId)) {
throw new TeamServiceError('invalid-response', 'Meeting response crossed team boundary') 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) { } catch (error) {
throw toTeamServiceError(error) throw toTeamServiceError(error)
} }
@ -681,6 +799,11 @@ export function subscribeToTeam(
{ event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${teamId}` }, { event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${teamId}` },
onRemoteChange, onRemoteChange,
) )
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'team_activities', filter: `team_id=eq.${teamId}` },
onRemoteChange,
)
.subscribe(onStatus) .subscribe(onStatus)
return { return {

View file

@ -840,7 +840,7 @@ function createStyles(palette: Palette) {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
padding: 18, padding: 18,
backgroundColor: 'rgba(0,0,0,0.72)', backgroundColor: palette.scrim,
}, },
modalCard: { maxHeight: '88%', padding: 0, overflow: 'hidden' }, modalCard: { maxHeight: '88%', padding: 0, overflow: 'hidden' },
modalContent: { padding: 18, gap: 16 }, modalContent: { padding: 18, gap: 16 },

View file

@ -603,7 +603,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
output: { gap: 7, padding: 12, borderWidth: 1, borderRadius: 9, borderColor: palette.accent.main }, output: { gap: 7, padding: 12, borderWidth: 1, borderRadius: 9, borderColor: palette.accent.main },
outputHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, outputHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
outputText: { lineHeight: 21 }, 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%' }, editor: { gap: 13, maxHeight: '92%' },
editorActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 9 }, editorActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 9 },
}) })

View file

@ -673,7 +673,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 20, padding: 20,
backgroundColor: 'rgba(0,0,0,0.72)', backgroundColor: palette.scrim,
}, },
modalCard: { modalCard: {
width: '100%', width: '100%',

View file

@ -1152,7 +1152,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
flex: 1, flex: 1,
padding: 20, padding: 20,
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.62)', backgroundColor: palette.scrim,
}, },
modalCard: { gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, modalCard: { gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' },
editorInput: { editorInput: {

View file

@ -772,7 +772,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
flex: 1, flex: 1,
padding: 20, padding: 20,
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.62)', backgroundColor: palette.scrim,
}, },
modalCard: { gap: 14, maxWidth: 560, maxHeight: '92%', width: '100%', alignSelf: 'center' }, modalCard: { gap: 14, maxWidth: 560, maxHeight: '92%', width: '100%', alignSelf: 'center' },
modalScroll: { flexShrink: 1 }, modalScroll: { flexShrink: 1 },

View file

@ -565,7 +565,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
tag: { minHeight: 38, justifyContent: 'center', paddingHorizontal: 10, borderRadius: 999, backgroundColor: palette.accent.dim }, tag: { minHeight: 38, justifyContent: 'center', paddingHorizontal: 10, borderRadius: 999, backgroundColor: palette.accent.dim },
addRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, addRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
addInput: { flex: 1, marginHorizontal: 0 }, 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 }, modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 },
actions: { flexDirection: 'row', alignItems: 'center', gap: 10 }, actions: { flexDirection: 'row', alignItems: 'center', gap: 10 },
flexButton: { flex: 1 }, flexButton: { flex: 1 },

View file

@ -22,6 +22,7 @@ import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import { import {
cancelTeamInvite, cancelTeamInvite,
createTeamActivity,
createTeamInvite, createTeamInvite,
deleteTeamRevisionSafe, deleteTeamRevisionSafe,
getTeamDetail, getTeamDetail,
@ -70,6 +71,8 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac
const [inviteRole, setInviteRole] = useState<'admin' | 'member'>('member') const [inviteRole, setInviteRole] = useState<'admin' | 'member'>('member')
const [editorError, setEditorError] = useState<TeamServiceErrorCode | null>(null) const [editorError, setEditorError] = useState<TeamServiceErrorCode | null>(null)
const [inviteResult, setInviteResult] = useState<TeamInviteResult | null>(null) const [inviteResult, setInviteResult] = useState<TeamInviteResult | null>(null)
const [activityBody, setActivityBody] = useState('')
const [postingActivity, setPostingActivity] = useState(false)
const load = useCallback(async (pull = false): Promise<void> => { const load = useCallback(async (pull = false): Promise<void> => {
const requestGeneration = ++generation.current const requestGeneration = ++generation.current
@ -123,6 +126,29 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac
const canAdminister = detail?.currentRole === 'owner' || detail?.currentRole === 'admin' const canAdminister = detail?.currentRole === 'owner' || detail?.currentRole === 'admin'
const isOwner = detail?.currentRole === 'owner' const isOwner = detail?.currentRole === 'owner'
const memberNameById = useMemo(() => {
const map = new Map<string, string>()
for (const member of detail?.members ?? []) {
map.set(member.userId, memberLabel(member, t('mobile.teams.unnamedMember')))
}
return map
}, [detail, t])
const postActivity = async (): Promise<void> => {
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 => { const openRename = (): void => {
if (detail === null) return if (detail === null) return
setName(detail.team.name) setName(detail.team.name)
@ -523,6 +549,45 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac
))} ))}
</ThemeCard> </ThemeCard>
<ThemeCard style={styles.section} testID="team-activity">
<ThemeText variant="heading">{t('mobile.teams.activity')}</ThemeText>
<TextInput
value={activityBody}
onChangeText={setActivityBody}
placeholder={t('mobile.teams.activityPlaceholder')}
multiline
maxLength={2000}
style={styles.activityInput}
testID="team-activity-input"
/>
<ThemeButton
label={postingActivity ? t('mobile.teams.posting') : t('mobile.teams.post')}
variant="secondary"
disabled={postingActivity || activityBody.trim().length === 0}
onPress={() => { void postActivity() }}
testID="team-activity-post"
/>
{detail.activities.length === 0 ? (
<ThemeText color="muted">{t('mobile.teams.noActivity')}</ThemeText>
) : detail.activities.map((activity) => (
<View key={activity.id} style={styles.activityRow} testID={`team-activity-${activity.id}`}>
<ThemeText variant="small" color="muted">
{activity.actorId !== null
? memberNameById.get(activity.actorId) ?? t('mobile.teams.unnamedMember')
: t('mobile.teams.system')}
{' · '}
{formatDate(new Date(activity.createdAt), {
dateStyle: 'medium',
timeStyle: 'short',
})}
</ThemeText>
{activity.body !== null && activity.body.length > 0 && (
<ThemeText>{activity.body}</ThemeText>
)}
</View>
))}
</ThemeCard>
<ThemeCard style={styles.section}> <ThemeCard style={styles.section}>
<ThemeText variant="heading">{t('mobile.teams.management')}</ThemeText> <ThemeText variant="heading">{t('mobile.teams.management')}</ThemeText>
{isOwner ? ( {isOwner ? (
@ -746,11 +811,30 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
backgroundColor: palette.bg.inset, backgroundColor: palette.bg.inset,
gap: 5, 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: { modalBackdrop: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
padding: 20, padding: 20,
backgroundColor: 'rgba(0,0,0,0.62)', backgroundColor: palette.scrim,
}, },
modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 }, modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 },
input: { input: {

View file

@ -303,7 +303,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
padding: 20, padding: 20,
backgroundColor: 'rgba(0,0,0,0.62)', backgroundColor: palette.scrim,
}, },
modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 }, modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 },
input: { input: {

View file

@ -685,7 +685,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, backgroundColor: palette.accent.dim }, badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, backgroundColor: palette.accent.dim },
actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, actions: { flexDirection: 'row', alignItems: 'center', gap: 8 },
flexButton: { flex: 1 }, 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 }, modalList: { width: '100%', maxWidth: 680, maxHeight: '94%', alignSelf: 'center', borderRadius: 14, backgroundColor: palette.bg.card },
modalCard: { padding: 18, gap: 14 }, modalCard: { padding: 18, gap: 14 },
editorSection: { gap: 14 }, editorSection: { gap: 14 },

View file

@ -49,6 +49,19 @@ export interface MobileThemePalette {
green: string green: string
blue: 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: { led: {
off: string off: string
} }
@ -96,6 +109,17 @@ const DARK_PALETTE: MobileThemePalette = {
green: '#4ade80', green: '#4ade80',
blue: '#60a5fa', 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' }, led: { off: '#111111' },
shadow: '#000000', shadow: '#000000',
} }
@ -140,6 +164,17 @@ const LIGHT_PALETTE: MobileThemePalette = {
green: '#167044', green: '#167044',
blue: '#175cd3', 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' }, led: { off: '#c6c6c2' },
shadow: '#4b4b50', shadow: '#4b4b50',
} }

View file

@ -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

View file

@ -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)

View file

@ -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 ?? ''
}
}
})

View file

@ -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 (
<Pressable onPress={onPress} style={styles.fab}>
<View style={styles.fabInner}>
<View style={styles.micIcon}>
<View style={styles.micBody} />
<View style={styles.micBase} />
</View>
</View>
</Pressable>
)
}
export default function TabsLayout(): React.ReactElement {
const router = useRouter()
const { user, loading } = useAuth()
useEffect(() => {
if (!loading && !user) {
router.replace('/login')
}
}, [user, loading, router])
return (
<Tabs
screenOptions={{
tabBarStyle: {
backgroundColor: 'rgba(25, 25, 27, 0.95)',
borderTopColor: d3roNativePalette.border.default,
borderTopWidth: 1,
height: 80,
paddingBottom: 20,
paddingTop: 8
},
tabBarActiveTintColor: d3roNativePalette.accent.main,
tabBarInactiveTintColor: d3roNativePalette.text.muted,
tabBarLabelStyle: {
fontFamily: MONO_FONT,
fontSize: 9,
fontWeight: '500',
letterSpacing: 0.5
},
headerStyle: { backgroundColor: d3roNativePalette.bg.app },
headerTintColor: d3roNativePalette.accent.main,
headerTitleStyle: { fontWeight: '300', letterSpacing: 1, fontFamily: MONO_FONT },
headerShown: false
}}
>
<Tabs.Screen
name="dash"
options={{
title: 'DASH',
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="dash" color={color} />
}}
/>
<Tabs.Screen
name="history"
options={{
title: 'HIST',
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="hist" color={color} />
}}
/>
<Tabs.Screen
name="record"
options={{
title: '',
tabBarIcon: () => <RecordFAB onPress={() => router.push('/(tabs)/record')} />,
tabBarLabel: () => null
}}
/>
<Tabs.Screen
name="talk"
options={{
title: 'TALK',
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="talk" color={color} />
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'SET',
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="set" color={color} />
}}
/>
</Tabs>
)
}
// 단순 아이콘 대용 (Phase M-2에서 SVG 아이콘으로 교체)
function TabIcon({ type, color }: { type: string; color: string }): React.ReactElement {
const iconStyles: Record<string, React.ReactElement> = {
dash: (
<View style={[styles.iconGrid, { borderColor: color }]}>
{[0, 1, 2, 3].map((i) => (
<View key={i} style={[styles.iconGridCell, { borderColor: color }]} />
))}
</View>
),
hist: (
<View style={[styles.iconCircle, { borderColor: color }]}>
<View style={[styles.iconClockHand, { backgroundColor: color }]} />
</View>
),
talk: (
<View style={[styles.iconChat, { borderColor: color }]} />
),
set: (
<View style={[styles.iconGear, { borderColor: color }]} />
)
}
return iconStyles[type] ?? <View />
}
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
}
})

View file

@ -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 (
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 100 }]}
>
<Header title={t('mobile.dash.title')} showBorder={false} paddingTop={insets.top} />
{/* Session Overview — InsetPanel */}
<ScreenPanel style={styles.overviewPanel}>
<PhosphorText variant="small" color="muted" style={styles.sectionLabel}>
{t('mobile.dash.sessionOverview')}
</PhosphorText>
<View style={styles.bigNumber}>
<PhosphorText variant="hero" color="amber">0</PhosphorText>
<PhosphorText variant="body" color="muted" style={styles.todayLabel}>
{t('mobile.dash.today')}
</PhosphorText>
</View>
<PhosphorText variant="small" color="muted" style={styles.guideText}>
{t('mobile.dash.tapToRecord')}
</PhosphorText>
<View style={styles.overviewFooter}>
<View>
<PhosphorText variant="label" color="muted">{t('mobile.dash.words')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</View>
<View style={styles.alignEnd}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.streak')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</View>
</View>
</ScreenPanel>
{/* 2x2 Stats Grid */}
<View style={styles.statsGrid}>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.rec')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
<PhosphorText variant="label" color="muted">{t('mobile.dash.min')}</PhosphorText>
</MetalCard>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.words')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.today')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.streak')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard>
</View>
{/* Backend Info */}
<MetalCard style={styles.infoCard}>
<PhosphorText variant="small" color="muted">{t('mobile.dash.backend')}</PhosphorText>
<View style={styles.infoRow}>
<Led color="green" size={6} />
<PhosphorText variant="body" color="amber" style={styles.infoValue}>
CLOUD (CLAUDE)
</PhosphorText>
</View>
</MetalCard>
<MetalCard style={styles.infoCard}>
<PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
<View style={styles.infoRow}>
<Led color="amber" size={6} />
<PhosphorText variant="body" color="amber" style={styles.infoValue}>
{t('mobile.dash.free')}
</PhosphorText>
</View>
</MetalCard>
{/* Usage */}
<PhosphorText variant="label" color="muted" style={styles.usageTitle}>
{t('mobile.dash.usage')}
</PhosphorText>
<MetalCard style={styles.usageCard}>
<UsageRow label={t('mobile.dash.dictation')} value={t('mobile.dash.unlimited')} />
<View style={styles.usageDivider} />
<UsageRow label={t('mobile.dash.llmProcess')} value={t('mobile.dash.unlimited')} />
<View style={styles.usageDivider} />
<UsageRow label={t('mobile.dash.premiumQuota')} value="250 / 500" progress={0.5} />
</MetalCard>
<AppStatusBar />
</ScrollView>
)
}
function UsageRow({
label,
value,
progress
}: {
label: string
value: string
progress?: number
}): React.ReactElement {
return (
<View style={styles.usageRow}>
<PhosphorText variant="body" color="primary">{label}</PhosphorText>
<View style={styles.usageRight}>
<PhosphorText variant="small" color="amber">{value}</PhosphorText>
{progress != null && (
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
)}
</View>
</View>
)
}
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
}
})

View file

@ -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<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [filter, setFilter] = useState<FilterType>('all')
const load = useCallback(async (): Promise<void> => {
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 (
<View style={styles.center}>
<ActivityIndicator size="large" color={d3roNativePalette.accent.main} />
</View>
)
}
return (
<View style={styles.container}>
<Header
title={t('mobile.hist.title')}
rightContent={<Led color="green" size={6} />}
paddingTop={insets.top}
/>
{/* Filter Chips */}
<View style={styles.filters}>
<FilterChip
label={t('mobile.hist.all')}
active={filter === 'all'}
onPress={() => setFilter('all')}
/>
<FilterChip
label={t('mobile.hist.saved')}
active={filter === 'favorites'}
onPress={() => setFilter('favorites')}
/>
<FilterChip
label={t('mobile.hist.processing')}
active={filter === 'processing'}
onPress={() => setFilter('processing')}
/>
</View>
<FlatList
data={grouped}
keyExtractor={(item) => item.id}
contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => { setRefreshing(true); void load() }}
tintColor={d3roNativePalette.accent.main}
/>
}
ListEmptyComponent={
<PhosphorText variant="body" color="muted" style={styles.emptyText}>
{t('mobile.hist.empty')}
</PhosphorText>
}
renderItem={({ item }) => {
if (item.type === 'header') {
return (
<PhosphorText variant="label" color="muted" style={styles.dateHeader}>
{item.label}
</PhosphorText>
)
}
const entry = item.entry
const isOld = !isToday(entry.created_at)
return (
<Pressable>
<MetalCard style={[styles.card, isOld && styles.cardOld]}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderLeft}>
<Led
color={entry.status === 'completed' ? 'green' : 'amber'}
size={6}
on={!isOld}
/>
<PhosphorText variant="label" color="primary" style={styles.timeLabel}>
{formatTime(new Date(entry.created_at).getTime())}
</PhosphorText>
</View>
{entry.word_count != null && (
<View style={styles.wordBadge}>
<PhosphorText variant="label" color="amber">
{entry.word_count} W
</PhosphorText>
</View>
)}
</View>
<PhosphorText
variant="body"
color="primary"
style={styles.transcriptText}
numberOfLines={2}
>
{entry.polished_text ?? entry.original_text ?? '(empty)'}
</PhosphorText>
<View style={styles.cardMeta}>
<PhosphorText variant="label" color="muted">
{entry.stt_model?.toUpperCase() ?? 'LOCAL'}
</PhosphorText>
</View>
</MetalCard>
</Pressable>
)
}}
ListFooterComponent={<AppStatusBar />}
/>
</View>
)
}
// 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 }
})

View file

@ -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<RecordingState>('idle')
const [transcript, setTranscript] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const [duration, setDuration] = useState(0)
const recordingRef = useRef<Audio.Recording | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
async function startRecording(): Promise<void> {
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<void> {
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<void> {
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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Header
title={state === 'recording' ? t('mobile.rec.session') : t('mobile.rec.title')}
paddingTop={insets.top}
rightContent={
state === 'recording' ? (
<View style={styles.headerRight}>
<PhosphorText variant="label" color="amber">
{formatTime(duration)}
</PhosphorText>
<Led color="green" size={6} />
</View>
) : undefined
}
/>
{/* Wave Bars */}
<WaveBars active={state === 'recording'} />
{/* Transcript Area */}
<View style={styles.transcriptArea}>
{state === 'idle' && (
<PhosphorText
variant="small"
color="muted"
style={styles.initLog}
>
{t('mobile.rec.initLog')}
</PhosphorText>
)}
{state === 'processing' && (
<ActivityIndicator size="large" color={d3roNativePalette.accent.main} />
)}
{transcript !== '' && state === 'done' && (
<View style={styles.transcriptBox}>
<PhosphorText variant="body" color="primary">
{transcript}
</PhosphorText>
</View>
)}
{error !== null && (
<View style={styles.errorBox}>
<PhosphorText variant="small" color="label">
{error}
</PhosphorText>
</View>
)}
</View>
{/* Status Line */}
{state === 'recording' && (
<View style={styles.listeningRow}>
<Led color="amber" size={6} />
<PhosphorText variant="label" color="amber" style={styles.listeningText}>
{t('mobile.rec.listening')}
</PhosphorText>
<PhosphorText variant="label" color="muted">
{t('mobile.rec.realtime')}
</PhosphorText>
</View>
)}
{/* Buttons */}
<View style={styles.buttons}>
{(state === 'idle' || state === 'done' || state === 'error') && (
<PhysicalButton
label={state === 'done' ? t('mobile.rec.newRecording') : t('mobile.rec.start')}
variant="primary"
onPress={() => void startRecording()}
/>
)}
{state === 'recording' && (
<>
<PhysicalButton
label={t('mobile.rec.stop')}
variant="danger"
onPress={() => void stopRecording()}
/>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.cancel')}
variant="secondary"
onPress={() => void cancelRecording()}
/>
</>
)}
</View>
<AppStatusBar />
</ScrollView>
)
}
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 }
})

View file

@ -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<void> {
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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<PhosphorText
variant="title"
color="primary"
style={[styles.title, { paddingTop: insets.top + 8 }]}
>
{t('mobile.set.title')}
</PhosphorText>
{/* Account Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
{t('mobile.set.accountPlan')}
</PhosphorText>
<MetalCard style={styles.section}>
<View style={styles.profileRow}>
<View style={styles.avatar}>
<PhosphorText variant="body" color="amber">{initials}</PhosphorText>
</View>
<View style={styles.profileInfo}>
<PhosphorText variant="body" color="primary">
{user?.email?.split('@')[0] ?? 'User'}
</PhosphorText>
<PhosphorText variant="label" color="muted">
{user?.email ?? '\u2014'}
</PhosphorText>
</View>
</View>
<View style={[styles.row, styles.rowInset]}>
<PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
<View style={styles.rowRight}>
<Led color="amber" size={6} />
<PhosphorText variant="body" color="amber" style={styles.rowValue}>
{t('mobile.dash.free')}
</PhosphorText>
</View>
</View>
</MetalCard>
{/* Backend Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
{t('mobile.set.backendConfig')}
</PhosphorText>
<MetalCard style={styles.section}>
<View style={styles.row}>
<View>
<PhosphorText variant="body" color="primary">{t('mobile.set.llmModel')}</PhosphorText>
<PhosphorText variant="label" color="muted">{t('mobile.set.llmDesc')}</PhosphorText>
</View>
<PhosphorText variant="body" color="amber">CLAUDE</PhosphorText>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View>
<PhosphorText variant="body" color="primary">{t('mobile.set.cloudStt')}</PhosphorText>
<PhosphorText variant="label" color="muted">{t('mobile.set.cloudSttDesc')}</PhosphorText>
</View>
<Switch
value={true}
trackColor={{
false: d3roNativePalette.bg.inset,
true: d3roNativePalette.accent.main
}}
thumbColor="#ffffff"
/>
</View>
</MetalCard>
{/* Preferences Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
{t('mobile.set.preferences')}
</PhosphorText>
<MetalCard style={styles.section}>
<View style={styles.row}>
<PhosphorText variant="body" color="primary">{t('mobile.set.language')}</PhosphorText>
<PhosphorText variant="body" color="muted">Korean</PhosphorText>
</View>
<View style={[styles.row, styles.rowBorder]}>
<PhosphorText variant="body" color="primary">{t('mobile.set.autoPolish')}</PhosphorText>
<Switch
value={true}
trackColor={{
false: d3roNativePalette.bg.inset,
true: d3roNativePalette.accent.main
}}
thumbColor="#ffffff"
/>
</View>
<View style={[styles.row, styles.rowBorder]}>
<PhosphorText variant="body" color="primary">{t('mobile.set.haptic')}</PhosphorText>
<Switch
value={true}
trackColor={{
false: d3roNativePalette.bg.inset,
true: d3roNativePalette.accent.main
}}
thumbColor="#ffffff"
/>
</View>
</MetalCard>
{/* Logout */}
<View style={styles.logoutWrap}>
<PhysicalButton
label={t('mobile.set.logout')}
variant="secondary"
onPress={() => void handleLogout()}
/>
</View>
{/* Status Footer */}
<AppStatusBar />
</ScrollView>
)
}
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 }
})

View file

@ -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<ScrollView>(null)
const [messages, setMessages] = useState<ChatMessage[]>([
{
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 (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={80}
>
<Header
title={t('mobile.talk.title')}
paddingTop={insets.top}
rightContent={
<View style={styles.headerRight}>
<PhosphorText variant="label" color="amber">{t('mobile.talk.live')}</PhosphorText>
<Led color="green" size={6} />
</View>
}
/>
{/* Chat Messages */}
<ScrollView
ref={scrollRef}
style={styles.chatArea}
contentContainerStyle={styles.chatContent}
>
{/* Date Badge */}
<View style={styles.dateBadge}>
<PhosphorText variant="label" color="muted">
{new Date().toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
}).toUpperCase()}
</PhosphorText>
</View>
{messages.map((msg) => (
<View
key={msg.id}
style={[
styles.bubble,
msg.role === 'user' ? styles.userBubble : styles.aiBubble
]}
>
<PhosphorText
variant="body"
color={msg.role === 'user' ? 'amber' : 'primary'}
style={styles.bubbleText}
>
{msg.content}
</PhosphorText>
<PhosphorText variant="label" color="muted" style={styles.bubbleLabel}>
{msg.role === 'user' ? t('mobile.talk.you') : t('mobile.talk.claude')}
</PhosphorText>
</View>
))}
</ScrollView>
{/* Input Area */}
<View style={styles.inputArea}>
<View style={styles.inputRow}>
<TextInput
style={styles.textInput}
placeholder={t('mobile.talk.placeholder')}
placeholderTextColor={d3roNativePalette.text.muted}
value={input}
onChangeText={setInput}
onSubmitEditing={handleSend}
returnKeyType="send"
/>
<Pressable style={styles.sendBtn} onPress={handleSend}>
<View style={styles.sendArrow} />
</Pressable>
</View>
<AppStatusBar style={styles.inputStatus} />
</View>
</KeyboardAvoidingView>
)
}
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 }
})

View file

@ -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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<I18nProvider initialLocale="ko">
<AuthProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: d3roNativePalette.bg.app },
headerTintColor: d3roNativePalette.accent.main,
contentStyle: { backgroundColor: d3roNativePalette.bg.app }
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</AuthProvider>
</I18nProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
)
}

View file

@ -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 (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#19191b' }}>
<ActivityIndicator size="large" color="#f25b29" />
</View>
)
}

View file

@ -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<void> {
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<void> {
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 (
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{/* Logo & Branding */}
<View style={styles.branding}>
<View style={styles.leds}>
<Led color="amber" size={10} />
<Led color="green" size={10} />
</View>
<PhosphorText variant="title" color="primary" style={styles.titleText}>
{t('mobile.login.title')}
</PhosphorText>
<PhosphorText variant="label" color="muted" style={styles.subtitleText}>
{t('mobile.login.subtitle')}
</PhosphorText>
</View>
{/* OAuth Buttons */}
<View style={styles.oauthSection}>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('google')}
disabled={busy}
>
<PhosphorText variant="body" color="primary">G</PhosphorText>
<PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.google')}
</PhosphorText>
</Pressable>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('apple')}
disabled={busy}
>
<PhosphorText variant="body" color="primary" style={styles.appleIcon}>
{'\uF8FF'}
</PhosphorText>
<PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.apple')}
</PhosphorText>
</Pressable>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('github')}
disabled={busy}
>
<PhosphorText variant="body" color="primary">{'\u2318'}</PhosphorText>
<PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.github')}
</PhosphorText>
</Pressable>
</View>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<PhosphorText variant="label" color="muted" style={styles.dividerText}>
{t('mobile.login.or')}
</PhosphorText>
<View style={styles.dividerLine} />
</View>
{/* Email/Password Fields */}
<View style={styles.formSection}>
<View>
<PhosphorText variant="label" color="muted" style={styles.fieldLabel}>
{t('mobile.login.email')}
</PhosphorText>
<TextInput
style={styles.input}
placeholder="user@studio.com"
placeholderTextColor="rgba(113, 113, 122, 0.3)"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View>
<PhosphorText variant="label" color="muted" style={styles.fieldLabel}>
{t('mobile.login.password')}
</PhosphorText>
<TextInput
style={styles.input}
placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
placeholderTextColor="rgba(113, 113, 122, 0.3)"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
</View>
</View>
{/* Login Button */}
<Pressable
style={({ pressed }) => [styles.loginBtn, pressed && { opacity: 0.9 }]}
onPress={() => void signInWithEmail()}
disabled={busy}
>
<PhosphorText variant="heading" style={styles.loginBtnText}>
{t('mobile.login.signIn')}
</PhosphorText>
</Pressable>
{/* Sign Up Link */}
<View style={styles.signupRow}>
<PhosphorText variant="small" color="muted">
{t('mobile.login.noAccount')}
</PhosphorText>
<Pressable>
<PhosphorText variant="small" color="amber">
{t('mobile.login.signUp')}
</PhosphorText>
</Pressable>
</View>
{/* DEV Skip */}
{__DEV__ && (
<Pressable style={styles.devSkipBtn} onPress={handleDevSkip}>
<PhosphorText variant="label" style={styles.devSkipText}>
{'\u26A1'} DEV SKIP LOGIN
</PhosphorText>
</Pressable>
)}
{/* Supabase Warning */}
{!configured && (
<View style={styles.warningBox}>
<PhosphorText variant="label" color="amber" style={styles.warningText}>
{'\u26A0'} {t('mobile.login.notConfigured')}
</PhosphorText>
</View>
)}
</ScrollView>
)
}
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 }
})

View file

@ -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<AuthContextValue>({
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<Session | null>(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 (
<AuthContext.Provider value={{ session, user: effectiveUser, loading, devBypass }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
return useContext(AuthContext)
}

View file

@ -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<void> {
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 등록 실패는 앱 진행 차단하지 않음
}
}

View file

@ -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()

View file

@ -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

View file

@ -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"
}
}

View file

@ -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"
]
}