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:
parent
bb0e54dcee
commit
94d8bb8ebe
32 changed files with 310 additions and 2229 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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}` },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.62)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
keyboardArea: { width: '100%', justifyContent: 'flex-end' },
|
||||
sheet: {
|
||||
|
|
|
|||
|
|
@ -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<TeamInviteListEntry[]> {
|
|||
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> {
|
||||
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<Tea
|
|||
.eq('team_id', teamId)
|
||||
.order('started_at', { ascending: false })
|
||||
.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.data === null) {
|
||||
throw new TeamServiceError('not-found', 'Team was not found')
|
||||
}
|
||||
if (meetingsResult.error !== null) throw meetingsResult.error
|
||||
if (activitiesResult.error !== null) throw activitiesResult.error
|
||||
const team = normalizeTeam(teamResult.data)
|
||||
const ownMembership = members.find((member) => member.userId === userId)
|
||||
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)) {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
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 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -673,7 +673,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(0,0,0,0.72)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
modalCard: {
|
||||
width: '100%',
|
||||
|
|
|
|||
|
|
@ -1152,7 +1152,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
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: {
|
||||
|
|
|
|||
|
|
@ -772,7 +772,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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<TeamServiceErrorCode | 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 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<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 => {
|
||||
if (detail === null) return
|
||||
setName(detail.team.name)
|
||||
|
|
@ -523,6 +549,45 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac
|
|||
))}
|
||||
</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}>
|
||||
<ThemeText variant="heading">{t('mobile.teams.management')}</ThemeText>
|
||||
{isOwner ? (
|
||||
|
|
@ -746,11 +811,30 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
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: {
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
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: {
|
||||
|
|
|
|||
|
|
@ -685,7 +685,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
22
apps/mobile/.gitignore
vendored
22
apps/mobile/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 등록 실패는 앱 진행 차단하지 않음
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue