feat: add mobile shell and unified search
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s

This commit is contained in:
Yun Chan 2026-09-28 23:23:36 +09:00
parent f505a03d34
commit 5ca3709b32
98 changed files with 5168 additions and 2093 deletions

View file

@ -50,28 +50,22 @@ name: Local paid admin hides ads and opens the role editor with bounded role cho
id: login-submit
- extendedWaitUntil:
visible:
id: dashboard-scroll
id: home-screen
timeout: 45000
- extendedWaitUntil:
visible:
id: dashboard-start-recording
id: record-fab
timeout: 45000
- scrollUntilVisible:
element:
text: "PRO"
direction: DOWN
timeout: 30000
visibilityPercentage: 50
- assertNotVisible:
id: free-tier-ad-slot
- takeScreenshot: admin-local-paid-admin-ad-hidden
- tapOn:
id: tab-settings
id: tab-more
retryTapIfNoChange: true
waitToSettleTimeoutMs: 2000
- extendedWaitUntil:
visible:
id: settings-screen
id: more-screen
timeout: 30000
- scrollUntilVisible:
element:

View file

@ -2,14 +2,14 @@ appId: com.d3ro.voice
name: Continue the same manager session and read the target user
---
- assertVisible:
id: dashboard-scroll
id: home-screen
- tapOn:
id: tab-settings
id: tab-more
retryTapIfNoChange: true
waitToSettleTimeoutMs: 2000
- extendedWaitUntil:
visible:
id: settings-screen
id: more-screen
timeout: 30000
- scrollUntilVisible:
element:

View file

@ -50,14 +50,8 @@ name: Local manager reads users and updates an existing subscription only
id: login-submit
- extendedWaitUntil:
visible:
id: dashboard-scroll
id: home-screen
timeout: 45000
- scrollUntilVisible:
element:
text: "PRO"
direction: DOWN
timeout: 30000
visibilityPercentage: 50
- assertNotVisible:
id: free-tier-ad-slot
- takeScreenshot: admin-local-manager-pro-session

View file

@ -2,14 +2,14 @@ appId: com.d3ro.voice
name: Continue the same ordinary session and prove Admin remains hidden
---
- assertVisible:
id: dashboard-scroll
id: home-screen
- tapOn:
id: tab-settings
id: tab-more
retryTapIfNoChange: true
waitToSettleTimeoutMs: 2000
- extendedWaitUntil:
visible:
id: settings-screen
id: more-screen
timeout: 30000
- scrollUntilVisible:
element:

View file

@ -50,7 +50,7 @@ name: Local ordinary free user hides Admin and shows the test ad slot
id: login-submit
- extendedWaitUntil:
visible:
id: dashboard-scroll
id: home-screen
timeout: 45000
- extendedWaitUntil:
visible:

View file

@ -50,23 +50,17 @@ name: Open a sensitive Admin list with a manager session before host-side downgr
id: login-submit
- extendedWaitUntil:
visible:
id: dashboard-scroll
id: home-screen
timeout: 45000
- scrollUntilVisible:
element:
text: "PRO"
direction: DOWN
timeout: 30000
visibilityPercentage: 50
- assertNotVisible:
id: free-tier-ad-slot
- tapOn:
id: tab-settings
id: tab-more
retryTapIfNoChange: true
waitToSettleTimeoutMs: 2000
- extendedWaitUntil:
visible:
id: settings-screen
id: more-screen
timeout: 30000
- scrollUntilVisible:
element:

View file

@ -50,23 +50,17 @@ name: Local super administrator promotes a manager to administrator
id: login-submit
- extendedWaitUntil:
visible:
id: dashboard-scroll
id: home-screen
timeout: 45000
- scrollUntilVisible:
element:
text: "PRO"
direction: DOWN
timeout: 30000
visibilityPercentage: 50
- assertNotVisible:
id: free-tier-ad-slot
- tapOn:
id: tab-settings
id: tab-more
retryTapIfNoChange: true
waitToSettleTimeoutMs: 2000
- extendedWaitUntil:
visible:
id: settings-screen
id: more-screen
timeout: 30000
- scrollUntilVisible:
element:

View file

@ -14,6 +14,12 @@ name: Authenticated mobile parity navigation and fail-closed billing
id: onboarding-language-en
- tapOn:
id: onboarding-audience-existing
- scrollUntilVisible:
element:
id: onboarding-skip
direction: DOWN
timeout: 15000
visibilityPercentage: 50
- tapOn:
id: onboarding-skip
- extendedWaitUntil:
@ -26,26 +32,32 @@ name: Authenticated mobile parity navigation and fail-closed billing
- tapOn:
id: login-password
- inputText: ${MOBILE_E2E_PASSWORD}
- hideKeyboard
- scrollUntilVisible:
element:
id: login-submit
direction: DOWN
timeout: 15000
visibilityPercentage: 50
- tapOn:
id: login-submit
- extendedWaitUntil:
visible:
id: dashboard-scroll
id: home-screen
timeout: 45000
- assertVisible:
id: free-tier-ad-slot
- takeScreenshot: authenticated-free-test-ad-slot
- tapOn:
id: tab-works
- assertVisible:
id: works-screen
- tapOn:
id: open-history
id: tab-records
- assertVisible:
id: history-search
- pressKey: BACK
- tapOn:
id: tab-record
id: record-fab
- assertVisible:
id: record-fab-menu
- tapOn:
id: record-fab-dictation
- assertVisible:
id: record-screen
- assertVisible:
@ -53,7 +65,7 @@ name: Authenticated mobile parity navigation and fail-closed billing
- tapOn:
id: record-start
- tapOn:
text: "Don.t allow"
text: 'Don.t allow'
optional: true
- extendedWaitUntil:
visible:
@ -75,13 +87,30 @@ name: Authenticated mobile parity navigation and fail-closed billing
id: record-start
timeout: 15000
- tapOn:
id: tab-talk
id: tab-more
- assertVisible:
id: more-screen
- scrollUntilVisible:
element:
id: open-talk
direction: DOWN
visibilityPercentage: 50
- tapOn:
id: open-talk
- assertVisible:
id: talk-messages
- assertVisible:
id: talk-ptt
- pressKey: BACK
- assertVisible:
id: more-screen
- scrollUntilVisible:
element:
id: more-open-settings
direction: DOWN
visibilityPercentage: 50
- tapOn:
id: tab-settings
id: more-open-settings
- assertVisible:
id: settings-screen
- scrollUntilVisible:
@ -121,22 +150,19 @@ name: Authenticated mobile parity navigation and fail-closed billing
- pressKey: BACK
- assertVisible:
id: settings-screen
- tapOn:
id: tab-works
- pressKey: BACK
- assertVisible:
id: works-screen
- scrollUntilVisible:
element:
id: open-meetings
direction: DOWN
visibilityPercentage: 50
id: more-screen
- tapOn:
id: open-meetings
id: tab-meetings
- extendedWaitUntil:
visible:
id: meetings-search
timeout: 30000
- pressKey: BACK
- tapOn:
id: tab-more
- assertVisible:
id: more-screen
- scrollUntilVisible:
element:
id: open-templates
@ -247,5 +273,5 @@ name: Authenticated mobile parity navigation and fail-closed billing
id: actions-screen
- pressKey: BACK
- assertVisible:
id: works-screen
id: more-screen
- takeScreenshot: authenticated-parity-complete

View file

@ -50,7 +50,7 @@ name: Local authenticated session for external incoming-media E2E
id: login-submit
- extendedWaitUntil:
visible:
id: dashboard-scroll
id: home-screen
timeout: 45000
- assertNotVisible:
id: free-tier-ad-slot

View file

@ -0,0 +1,120 @@
appId: com.d3ro.voice
name: Mobile shell and unified search local proof
---
- launchApp:
clearState: true
stopApp: true
permissions:
all: allow
- extendedWaitUntil:
visible:
id: onboarding-screen
timeout: 30000
- tapOn:
id: onboarding-language-en
- tapOn:
id: onboarding-audience-existing
- scrollUntilVisible:
element:
id: onboarding-skip
direction: DOWN
timeout: 15000
visibilityPercentage: 50
- tapOn:
id: onboarding-skip
- extendedWaitUntil:
visible:
id: login-email
timeout: 30000
- tapOn:
id: login-email
- inputText: ${MOBILE_E2E_EMAIL}
- tapOn:
id: login-password
- inputText: ${MOBILE_E2E_PASSWORD}
- hideKeyboard
- scrollUntilVisible:
element:
id: login-submit
direction: DOWN
timeout: 15000
visibilityPercentage: 50
- tapOn:
id: login-submit
- extendedWaitUntil:
visible:
id: home-screen
timeout: 45000
- assertVisible:
id: tab-home
- assertVisible:
id: tab-records
- assertVisible:
id: tab-meetings
- assertVisible:
id: tab-more
- takeScreenshot: navigation-shell-home
- tapOn:
id: record-fab
- assertVisible:
id: record-fab-menu
- takeScreenshot: navigation-shell-record-menu
- pressKey: BACK
- assertNotVisible:
id: record-fab-menu
- assertVisible:
id: home-open-unified-search
- tapOn:
id: home-open-unified-search
- extendedWaitUntil:
visible:
id: unified-search-input
timeout: 15000
- tapOn:
id: unified-search-input
- inputText: ${MOBILE_E2E_QUERY}
- extendedWaitUntil:
visible:
id: unified-search-open-record-${MOBILE_E2E_RECORD_ID}
timeout: 30000
- assertVisible:
id: unified-search-open-meeting-${MOBILE_E2E_MEETING_ID}
- takeScreenshot: unified-search-results
- tapOn:
id: unified-search-open-record-${MOBILE_E2E_RECORD_ID}
- extendedWaitUntil:
visible:
id: history-detail-favorite
timeout: 30000
- takeScreenshot: unified-search-record-detail
- pressKey: BACK
- extendedWaitUntil:
visible:
id: unified-search-open-meeting-${MOBILE_E2E_MEETING_ID}
timeout: 15000
- tapOn:
id: unified-search-open-meeting-${MOBILE_E2E_MEETING_ID}
- extendedWaitUntil:
visible:
id: meeting-detail-title
timeout: 30000
- takeScreenshot: unified-search-meeting-detail
- pressKey: BACK
- pressKey: BACK
- extendedWaitUntil:
visible:
id: home-screen
timeout: 15000
- tapOn:
id: tab-records
- assertVisible:
id: history-search
- tapOn:
id: tab-meetings
- assertVisible:
id: meetings-search
- tapOn:
id: tab-more
- assertVisible:
id: more-screen
- takeScreenshot: navigation-shell-more

View file

@ -33,12 +33,10 @@ import ForgotPasswordScreen from './src/screens/ForgotPasswordScreen'
import UpdatePasswordScreen from './src/screens/UpdatePasswordScreen'
import AccountScreen from './src/screens/AccountScreen'
import OnboardingScreen from './src/screens/OnboardingScreen'
import HistoryScreen from './src/screens/HistoryScreen'
import HistoryDetailScreen from './src/screens/HistoryDetailScreen'
import DevicesScreen from './src/screens/DevicesScreen'
import DictionaryScreen from './src/screens/DictionaryScreen'
import CommandsScreen from './src/screens/CommandsScreen'
import MeetingsScreen from './src/screens/MeetingsScreen'
import MeetingDetailScreen from './src/screens/MeetingDetailScreen'
import KnowledgeScreen from './src/screens/KnowledgeScreen'
import ActionsScreen from './src/screens/ActionsScreen'
@ -51,6 +49,9 @@ import TemplatesScreen from './src/screens/TemplatesScreen'
import MemosScreen from './src/screens/MemosScreen'
import AdminScreen from './src/screens/AdminScreen'
import AdminUserDetailScreen from './src/screens/AdminUserDetailScreen'
import TalkScreen from './src/screens/TalkScreen'
import SettingsScreen from './src/screens/SettingsScreen'
import UnifiedSearchScreen from './src/screens/UnifiedSearchScreen'
import { EntitlementProvider } from './src/lib/entitlement-context'
import { fetchEntitlementSnapshot, useEntitlement } from './src/lib/entitlement-context'
import { BillingProvider } from './src/lib/billing-context'
@ -81,13 +82,11 @@ export type RootStackParamList = {
Onboarding: { replay?: boolean } | undefined
Main: NavigatorScreenParams<MainTabParamList> | undefined
Account: undefined
History: undefined
HistoryDetail: { historyId: string }
ProPaywall: undefined
Devices: undefined
Dictionary: undefined
Commands: undefined
Meetings: undefined
MeetingDetail: { meetingId: string }
Knowledge: undefined
Actions: undefined
@ -100,6 +99,9 @@ export type RootStackParamList = {
Memos: undefined
Admin: undefined
AdminUserDetail: { userId: string }
Talk: undefined
Settings: undefined
UnifiedSearch: { initialQuery?: string } | undefined
}
type AuthEntryRoute = 'Login' | 'SignUp'
@ -389,12 +391,10 @@ function RootNavigator(): React.ReactElement {
<>
<Stack.Screen name="Main" component={TabNavigator} />
<Stack.Screen name="Account" component={AccountScreen} />
<Stack.Screen name="History" component={HistoryScreen} />
<Stack.Screen name="HistoryDetail" component={HistoryDetailScreen} />
<Stack.Screen name="Devices" component={DevicesScreen} />
<Stack.Screen name="Dictionary" component={DictionaryScreen} />
<Stack.Screen name="Commands" component={CommandsScreen} />
<Stack.Screen name="Meetings" component={MeetingsScreen} />
<Stack.Screen name="MeetingDetail" component={MeetingDetailScreen} />
<Stack.Screen name="Knowledge" component={KnowledgeScreen} />
<Stack.Screen name="Actions" component={ActionsScreen} />
@ -411,6 +411,9 @@ function RootNavigator(): React.ReactElement {
<Stack.Screen name="Memos" component={MemosScreen} />
<Stack.Screen name="Admin" component={AdminScreen} />
<Stack.Screen name="AdminUserDetail" component={AdminUserDetailScreen} />
<Stack.Screen name="Talk" component={TalkScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="UnifiedSearch" component={UnifiedSearchScreen} />
<Stack.Screen name="UpdatePassword" component={UpdatePasswordScreen} />
<Stack.Screen
name="Onboarding"

View file

@ -7,6 +7,7 @@ import { IdentityManagementService } from '../src/features/auth/identity-linking
const USER_ID = '11111111-1111-4111-8111-111111111111'
const mockCapabilities = jest.fn()
const mockOpenUrl = jest.spyOn(Linking, 'openURL')
let mockProfileTier: 'free' | 'pro' | 'pro_plus' = 'free'
const mockI18n = {
t: (key: string, params?: Record<string, unknown>) => params === undefined ? key : `${key}:${JSON.stringify(params)}`,
formatDate: () => '2026-08-21',
@ -20,7 +21,9 @@ const mockUser = {
identities: [],
}
jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: jest.fn() }) }))
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }),
}))
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
@ -40,8 +43,24 @@ jest.mock('../src/lib/preferences-context', () => ({
bg: { app: '#000', card: '#181818', inset: '#202020', cardHover: '#222' },
text: { primary: '#fff', muted: '#999', white: '#fff', onAccent: '#000' },
border: { default: '#444', subtle: '#333', strong: '#666' },
accent: { main: '#7cf', dim: '#234' },
accent: {
main: '#7cf',
light: '#7cf',
dim: '#234',
solid: '#37c',
solidHover: '#258',
ink: '#fff',
},
tag: { red: '#f66', green: '#6f6', orange: '#fa6' },
status: {
success: '#6f6',
warning: '#fa6',
danger: '#f66',
info: '#7cf',
dangerBg: '#411',
},
overlay: { soft: '#222' },
led: { off: '#555' },
},
}),
clearAllUserPreferenceCaches: jest.fn(),
@ -53,7 +72,7 @@ jest.mock('../src/lib/supabase', () => ({
select: () => ({
eq: () => ({
single: async () => ({
data: { id: USER_ID, name: 'User', avatar_url: null, locale: 'en', tier: 'free', created_at: '', updated_at: '' },
data: { id: USER_ID, name: 'User', avatar_url: null, locale: 'en', tier: mockProfileTier, created_at: '', updated_at: '' },
error: null,
}),
}),
@ -118,6 +137,7 @@ async function flush(): Promise<void> {
describe('AccountScreen identity management', () => {
beforeEach(() => {
mockProfileTier = 'free'
mockCapabilities.mockResolvedValue({
signUpEnabled: true,
emailEnabled: true,
@ -149,4 +169,17 @@ describe('AccountScreen identity management', () => {
})
expect(mockOpenUrl).toHaveBeenCalledWith('https://provider.example/authorize')
})
it('shows the localized PRO+ label for a pro_plus profile', async () => {
mockProfileTier = 'pro_plus'
let renderer: ReactTestRenderer.ReactTestRenderer
await act(async () => {
renderer = create(<AccountScreen identityService={createIdentityService()} />)
})
await flush()
expect(renderer!.root.findAllByProps({ children: 'mobile.paywall.proPlus' }).length).toBeGreaterThan(0)
expect(renderer!.root.findAllByProps({ children: 'mobile.account.tierUnknown' })).toHaveLength(0)
})
})

View file

@ -9,18 +9,12 @@ jest.mock('@d3ro/i18n', () => ({
params?.id === undefined ? key : `${key}:${params.id}`
) }),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', card: '#181818', inset: '#202020', sidebar: '#111', cardHover: '#222' },
text: { primary: '#fff', secondary: '#ddd', muted: '#999', onAccent: '#000' },
border: { subtle: '#333', default: '#444', strong: '#666' },
accent: { main: '#fc0', dim: '#332b00', pressed: '#db0' },
tag: { red: '#f66', green: '#5d8' },
shadow: '#000',
},
}),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: () => ({ palette: d3roNativeDarkPalette }),
}
})
jest.mock('../src/lib/random-id', () => ({
createUuidV4: () => '22222222-2222-4222-8222-222222222222',
}))

View file

@ -16,17 +16,12 @@ jest.mock('react-native-safe-area-context', () => ({
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', card: '#181818', inset: '#202020', cardHover: '#222' },
text: { primary: '#fff', muted: '#999', onAccent: '#000' },
border: { default: '#444', strong: '#666' },
accent: { main: '#fc0', dim: '#221' },
tag: { green: '#6f6', red: '#f66', orange: '#fa6', blue: '#6af' },
},
}),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: () => ({ palette: d3roNativeDarkPalette }),
}
})
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({ authError: null }),
}))

View file

@ -10,6 +10,7 @@ const mockNavigate = jest.fn()
jest.mock('@react-navigation/native', () => {
const ReactValue = require('react') as typeof React
return {
useNavigation: () => ({ goBack: jest.fn() }),
useFocusEffect: (callback: () => void | (() => void)) => {
ReactValue.useEffect(callback, [])
},
@ -18,6 +19,9 @@ jest.mock('@react-navigation/native', () => {
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('../src/navigation/tab-layout', () => ({
useTabScreenBottomPadding: () => 88,
}))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => params === undefined
@ -94,7 +98,7 @@ jest.mock('../src/features/templates', () => ({
loadTemplateLibrary: (...args: unknown[]) => mockLoadTemplateLibrary(...args),
}))
import { TextInput } from 'react-native'
import { FlatList, StyleSheet, TextInput } from 'react-native'
import MeetingsScreen from '../src/screens/MeetingsScreen'
const USER_ID = '11111111-1111-4111-8111-111111111111'
@ -175,6 +179,23 @@ describe('meeting metadata creation screen', () => {
mockCreateMeetingWorkspaceWithMetadata.mockResolvedValue(createdMeeting())
})
test('uses the shared tab and FAB clearance for list content', async () => {
const navigation = {
getParent: () => null,
navigate: mockNavigate,
}
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<MeetingsScreen navigation={navigation as never} />)
})
await flush()
const list = renderer!.root.findByType(FlatList)
expect(StyleSheet.flatten(list.props.contentContainerStyle).paddingBottom).toBe(88)
expect(list.props.ListFooterComponent).toBeNull()
await act(() => renderer!.unmount())
})
test('submits title, attendees, language, and an explicit template then opens server readback', async () => {
const navigation = {
getParent: () => null,
@ -300,4 +321,37 @@ describe('meeting metadata creation screen', () => {
})
await act(() => renderer!.unmount())
})
test('consumes each FAB create request exactly once', async () => {
const setParams = jest.fn()
const navigation = {
getParent: () => null,
navigate: mockNavigate,
setParams,
}
const route = { params: { createRequest: 1700000000000 } }
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(
<MeetingsScreen navigation={navigation as never} route={route} />,
)
})
await flush()
expect(setParams).toHaveBeenCalledWith({ createRequest: undefined })
expect(mockLoadTemplateLibrary).toHaveBeenCalledTimes(1)
expect(
renderer!.root.findAllByProps({ testID: 'meeting-editor-modal' }).length,
).toBeGreaterThan(0)
await act(async () => {
renderer!.update(
<MeetingsScreen navigation={navigation as never} route={route} />,
)
})
await flush()
expect(mockLoadTemplateLibrary).toHaveBeenCalledTimes(1)
await act(() => renderer!.unmount())
})
})

View file

@ -1,26 +1,17 @@
import React from 'react'
import ReactTestRenderer, { act } from 'react-test-renderer'
import { I18nProvider } from '@d3ro/i18n'
jest.mock('../src/lib/auth-context', () => ({ useAuth: jest.fn() }))
jest.mock('../src/lib/entitlement-context', () => ({ useEntitlement: jest.fn() }))
jest.mock('../src/lib/native-config', () => ({ getMobileRuntimeConfig: jest.fn() }))
jest.mock('../src/lib/supabase', () => ({ supabase: { from: jest.fn() } }))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: jest.fn(() => ({
palette: {
accent: { main: '#3366ff' },
bg: { inset: '#111111' },
border: { subtle: '#222222' },
tag: { green: '#00aa66', red: '#cc3344' },
text: {
primary: '#ffffff',
secondary: '#dddddd',
muted: '#999999',
onAccent: '#ffffff',
},
},
})),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: jest.fn(() => ({ palette: d3roNativeDarkPalette })),
}
})
const mockedAuth = jest.requireMock('../src/lib/auth-context') as { useAuth: jest.Mock }
const mockedEntitlement = jest.requireMock('../src/lib/entitlement-context') as {
@ -149,7 +140,9 @@ describe('mobile rewarded ad lifecycle', () => {
let renderer: ReactTestRenderer.ReactTestRenderer
await act(async () => {
renderer = ReactTestRenderer.create(
<MobileAdsProvider><FreeTierBanner /></MobileAdsProvider>,
<I18nProvider initialLocale="en">
<MobileAdsProvider><FreeTierBanner /></MobileAdsProvider>
</I18nProvider>,
)
await Promise.resolve()
await Promise.resolve()
@ -157,7 +150,7 @@ describe('mobile rewarded ad lifecycle', () => {
expect(latestBannerProps?.unitId).toBe('ca-app-pub-3940256099942544/6300978111')
act(() => latestBannerProps?.onAdFailedToLoad())
expect(JSON.stringify(renderer!.toJSON())).toContain('AD UNAVAILABLE')
expect(JSON.stringify(renderer!.toJSON())).toContain('Ad unavailable')
await act(async () => {
jest.advanceTimersByTime(30_000)
@ -167,7 +160,7 @@ describe('mobile rewarded ad lifecycle', () => {
expect(latestBannerProps?.unitId).toBe('ca-app-pub-3940256099942544/6300978111')
act(() => latestBannerProps?.onAdLoaded())
expect(JSON.stringify(renderer!.toJSON())).not.toContain('AD UNAVAILABLE')
expect(JSON.stringify(renderer!.toJSON())).not.toContain('Ad unavailable')
act(() => renderer!.unmount())
jest.useRealTimers()
})

View file

@ -0,0 +1,321 @@
import React from 'react'
import {
AccessibilityInfo,
BackHandler,
Keyboard,
StyleSheet,
} from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
const mockNavigate = jest.fn()
const mockTabNavigate = jest.fn()
let mockActiveTab = 'Home'
let mockAppMetadata: Record<string, unknown> = {}
type MockTabOptions = {
tabBarButton?: () => React.ReactNode;
tabBarItemStyle?: unknown;
}
type MockTabScreen = {
name: string;
options: MockTabOptions;
}
let mockTabScreens: MockTabScreen[] = []
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: mockNavigate }),
}))
jest.mock('@react-navigation/bottom-tabs', () => {
const ReactRuntime = require('react') as typeof import('react')
const { Text, View } = require('react-native') as typeof import('react-native')
type MockScreenProps = {
name: string;
component: React.ComponentType<unknown>;
options?: MockTabOptions;
}
type MockRoute = { key: string; name: string }
type MockDescriptor = { options: MockTabOptions }
type MockTabBarProps = {
state: { index: number; routes: MockRoute[] };
descriptors: Record<string, MockDescriptor>;
navigation: { navigate: typeof mockTabNavigate };
insets: { top: number; bottom: number; left: number; right: number };
}
function MockScreen(_props: MockScreenProps): null {
return null
}
function MockBottomTabBar({ state, descriptors }: MockTabBarProps): React.ReactElement {
return (
<View testID="mock-bottom-tab-bar">
{state.routes.map((route) => {
const options = descriptors[route.key]?.options ?? {}
const hidden = options.tabBarButton?.() === null
return hidden ? null : (
<Text key={route.key} testID={`bottom-tab-${route.name}`}>
{route.name}
</Text>
)
})}
</View>
)
}
function MockNavigator({
children,
tabBar,
}: React.PropsWithChildren<{
tabBar: (props: MockTabBarProps) => React.ReactNode;
backBehavior?: string;
screenOptions?: Record<string, unknown>;
}>): React.ReactElement {
const screens = ReactRuntime.Children.toArray(children).filter(
(child): child is React.ReactElement<MockScreenProps> => ReactRuntime.isValidElement(child),
)
mockTabScreens = screens.map((screen) => ({
name: screen.props.name,
options: screen.props.options ?? {},
}))
const routes = mockTabScreens.map((screen, index) => ({
key: `${screen.name}-${index}`,
name: screen.name,
}))
const activeIndex = routes.findIndex((route) => route.name === mockActiveTab)
const descriptors = Object.fromEntries(routes.map((route, index) => [
route.key,
{ options: mockTabScreens[index]?.options ?? {} },
]))
return (
<View testID="mock-tab-navigator">
{tabBar({
state: { index: activeIndex, routes },
descriptors,
navigation: { navigate: mockTabNavigate },
insets: { top: 0, bottom: 0, left: 0, right: 0 },
})}
</View>
)
}
return {
BottomTabBar: MockBottomTabBar,
createBottomTabNavigator: () => ({ Navigator: MockNavigator, Screen: MockScreen }),
}
})
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('../src/navigation/tab-layout', () => ({
useTabScreenBottomPadding: () => 24,
}))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (
params === undefined ? key : `${key}:${JSON.stringify(params)}`
),
}),
}))
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({ user: { app_metadata: mockAppMetadata } }),
}))
jest.mock('../src/lib/entitlement-context', () => ({
useEntitlement: () => ({ snapshot: { tier: 'free' } }),
}))
jest.mock('../src/components/FreeTierBanner', () => () => null)
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: 'app', cardHover: 'hover', elevated: 'elevated' },
text: { primary: 'primary', muted: 'muted', onAccent: 'on-accent' },
border: { default: 'default', subtle: 'subtle' },
accent: {
main: 'accent',
pressed: 'pressed',
solid: 'solid',
solidHover: 'solid-hover',
ink: 'accent-ink',
},
scrim: 'scrim',
shadow: 'shadow',
},
}),
}))
jest.mock('../src/theme/themed-components', () => {
const { Text } = require('react-native') as typeof import('react-native')
return {
ThemeText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<Text {...props}>{children}</Text>
),
}
})
import MoreScreen from '../src/screens/MoreScreen'
import RecordFab from '../src/components/RecordFab'
import TabNavigator from '../src/navigation/TabNavigator'
async function render(element: React.ReactElement): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | undefined
await act(async () => {
renderer = create(element)
await Promise.resolve()
})
if (renderer === undefined) throw new Error('Component did not render')
return renderer
}
describe('mobile navigation shell', () => {
beforeEach(() => {
jest.clearAllMocks()
mockActiveTab = 'Home'
mockTabScreens = []
mockAppMetadata = {}
jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(false)
jest.spyOn(AccessibilityInfo, 'addEventListener').mockReturnValue({ remove: jest.fn() })
jest.spyOn(Keyboard, 'addListener').mockReturnValue({ remove: jest.fn() } as never)
})
test('TabNavigator exposes four visible destinations and keeps Record hidden', async () => {
const renderer = await render(<TabNavigator />)
expect(mockTabScreens.map((screen) => screen.name)).toEqual([
'Home',
'Records',
'Meetings',
'More',
'Record',
])
;['Home', 'Records', 'Meetings', 'More'].forEach((route) => {
expect(
renderer.root.findAllByProps({ testID: `bottom-tab-${route}` }).length,
).toBeGreaterThan(0)
})
expect(renderer.root.findAllByProps({ testID: 'bottom-tab-Record' })).toHaveLength(0)
const record = mockTabScreens.find((screen) => screen.name === 'Record')
expect(record?.options.tabBarButton?.()).toBeNull()
expect(StyleSheet.flatten(record?.options.tabBarItemStyle)).toMatchObject({ display: 'none' })
})
test.each([
['Home', true],
['Records', true],
['Meetings', true],
['More', false],
['Record', false],
])('FAB visibility follows the active %s route', async (route, visible) => {
mockActiveTab = route
const renderer = await render(<TabNavigator />)
expect(renderer.root.findAllByProps({ testID: 'record-fab' }).length > 0).toBe(visible)
})
test('FAB actions dispatch the exact dictation, meeting, and import routes', async () => {
const now = jest.spyOn(Date, 'now')
.mockReturnValue(1_700_000_000_001)
const renderer = await render(<TabNavigator />)
for (const action of ['dictation', 'meeting', 'import'] as const) {
await act(async () => {
renderer.root.findByProps({ testID: 'record-fab' }).props.onPress()
})
await act(async () => {
renderer.root.findByProps({ testID: `record-fab-${action}` }).props.onPress()
})
}
expect(mockTabNavigate).toHaveBeenNthCalledWith(1, 'Record')
expect(mockTabNavigate).toHaveBeenNthCalledWith(2, 'Meetings', {
createRequest: 1_700_000_000_001,
})
expect(mockTabNavigate).toHaveBeenNthCalledWith(3, 'Record', {
importRequest: 1_700_000_000_001,
})
expect(mockTabNavigate).toHaveBeenCalledTimes(3)
now.mockRestore()
})
test('More preserves tools and account routes, with admin gated by role', async () => {
const renderer = await render(<MoreScreen />)
const routeIds = [
'open-talk',
'open-commands',
'open-dictionary',
'open-knowledge',
'open-templates',
'open-memos',
'open-teams',
'open-actions',
'open-notifications',
'open-data-portability',
'open-account',
'open-paywall',
'open-devices',
'more-open-settings',
]
routeIds.forEach((testID) => {
expect(renderer.root.findAllByProps({ testID }).length).toBeGreaterThan(0)
})
expect(renderer.root.findAllByProps({ testID: 'open-admin' })).toHaveLength(0)
await act(async () => {
renderer.root.findByProps({ testID: 'open-talk' }).props.onPress()
renderer.root.findByProps({ testID: 'more-open-settings' }).props.onPress()
})
expect(mockNavigate).toHaveBeenNthCalledWith(1, 'Talk')
expect(mockNavigate).toHaveBeenNthCalledWith(2, 'Settings')
mockAppMetadata = { role: 'admin' }
await act(async () => {
renderer.update(<MoreScreen />)
})
expect(renderer.root.findAllByProps({ testID: 'open-admin' }).length).toBeGreaterThan(0)
})
test('FAB menu closes on Android back, scrim, and selection', async () => {
let hardwareBack: (() => boolean) | null = null
jest.spyOn(BackHandler, 'addEventListener').mockImplementation((_event, listener) => {
hardwareBack = listener
return { remove: jest.fn() }
})
const onSelect = jest.fn()
const renderer = await render(
<RecordFab visible bottomOffset={72} onSelect={onSelect} />,
)
const fab = renderer.root.findByProps({ testID: 'record-fab' })
expect(StyleSheet.flatten(fab.props.style({ pressed: false })).backgroundColor).toBe('solid')
expect(StyleSheet.flatten(fab.props.style({ pressed: true })).backgroundColor).toBe('solid-hover')
await act(async () => {
fab.props.onPress()
})
expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' }).length).toBeGreaterThan(0)
expect(hardwareBack).not.toBeNull()
await act(async () => {
expect(hardwareBack?.()).toBe(true)
})
expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' })).toHaveLength(0)
await act(async () => {
renderer.root.findByProps({ testID: 'record-fab' }).props.onPress()
})
await act(async () => {
renderer.root.findByProps({ testID: 'record-fab-scrim' }).props.onPress()
})
expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' })).toHaveLength(0)
await act(async () => {
renderer.root.findByProps({ testID: 'record-fab' }).props.onPress()
})
await act(async () => {
renderer.root.findByProps({ testID: 'record-fab-meeting' }).props.onPress()
})
expect(onSelect).toHaveBeenCalledWith('meeting')
expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' })).toHaveLength(0)
})
})

View file

@ -15,10 +15,15 @@ const mockValidateLocalAudio = jest.fn()
const mockCleanup = jest.fn(async () => undefined)
const mockStart = jest.fn(async () => undefined)
const mockStop = jest.fn()
const mockPickAudioFile = jest.fn()
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ goBack: jest.fn() }),
}))
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('../src/components/ScreenHeader', () => ({ ScreenHeader: () => null }))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({ t: (key: string) => key, locale: 'ko' }),
}))
@ -28,7 +33,10 @@ jest.mock('../src/lib/auth-context', () => ({
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: 'app', card: 'card', inset: 'inset' },
bg: { app: 'app', sidebar: 'sidebar', card: 'card', inset: 'inset' },
text: { primary: 'primary', muted: 'muted' },
border: { default: 'default' },
overlay: { soft: 'soft' },
accent: { main: 'accent' },
tag: { red: 'red' },
},
@ -56,7 +64,9 @@ jest.mock('@d3ro/ui-native', () => {
<Text>{label}</Text>
</Pressable>
),
d3roNativeFonts: { mono: 'mono' },
d3roNativeFonts: { sans: 'sans' },
d3roNativeControl: { h: 48 },
d3roNativeRadius: { button: 8 },
}
})
jest.mock('../src/lib/audio-recorder', () => {
@ -81,7 +91,9 @@ jest.mock('../src/lib/audio-recorder', () => {
jest.mock('../src/features/import/audio-validation', () => ({
validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options),
}))
jest.mock('../src/features/import/audio-file-picker', () => ({ pickAudioFile: jest.fn() }))
jest.mock('../src/features/import/audio-file-picker', () => ({
pickAudioFile: (...args: unknown[]) => mockPickAudioFile(...args),
}))
jest.mock('../src/features/import/incoming-media-intent', () => ({ consumeIncomingMediaAudio: jest.fn() }))
jest.mock('../src/features/notifications/notification-native', () => ({
showLocalTranscriptionCompleted: jest.fn(async () => true),
@ -143,7 +155,12 @@ function meetingResult(): AudioPipelineResult {
}
}
type Params = { meetingId?: string; meetingTitle?: string; meetingLanguage?: string }
type Params = {
meetingId?: string
meetingTitle?: string
meetingLanguage?: string
importRequest?: number
}
async function render(params: Params, setParams: (next: Params) => void): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | undefined
@ -238,4 +255,33 @@ describe('RecordScreen meeting durability (redteam r2-27)', () => {
expect(mockBeginMeetingRecording).toHaveBeenCalledTimes(1)
expect(mockStart).toHaveBeenCalledTimes(2)
})
test('consumes each FAB import request exactly once', async () => {
mockPickAudioFile.mockRejectedValue(
new AudioPipelineError('cancelled', 'Picker cancelled'),
)
const setParams = jest.fn()
const params = { importRequest: 1700000000000 }
const renderer = await render(params, setParams)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(setParams).toHaveBeenCalledWith({ importRequest: undefined })
expect(mockPickAudioFile).toHaveBeenCalledTimes(1)
await act(async () => {
renderer.update(
<RecordScreen
route={{ params }}
navigation={{ getParent: () => undefined, setParams }}
/>,
)
await Promise.resolve()
})
expect(mockPickAudioFile).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,48 @@
import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { StyleSheet } from 'react-native'
const mockGoBack = jest.fn()
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ goBack: mockGoBack }),
}))
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 10, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { sidebar: '#111827' },
text: { primary: '#ffffff', secondary: '#d1d5db', tertiary: '#9ca3af' },
border: { default: '#374151' },
accent: { light: '#60a5fa', ink: '#ffffff' },
tag: { green: '#34d399', red: '#f87171' },
status: { success: '#34d399', danger: '#f87171' },
overlay: { soft: 'rgba(255,255,255,0.06)' },
},
}),
}))
import { ScreenHeader } from '../src/components/ScreenHeader'
describe('ScreenHeader', () => {
test('renders a 48dp back action for stack screens and omits it for tab roots', () => {
let renderer: ReactTestRenderer
act(() => {
renderer = create(<ScreenHeader title="Title" testID="header" />)
})
const back = renderer!.root.findByProps({ accessibilityLabel: 'common.back' })
expect(StyleSheet.flatten(back.props.style({ pressed: false })).width).toBe(48)
act(() => back.props.onPress())
expect(mockGoBack).toHaveBeenCalledTimes(1)
act(() => renderer!.update(<ScreenHeader title="Title" showBack={false} />))
expect(renderer!.root.findAllByProps({ accessibilityLabel: 'common.back' })).toHaveLength(0)
act(() => renderer!.unmount())
})
})

View file

@ -22,17 +22,12 @@ jest.mock('@d3ro/i18n', () => ({
t: (key: string) => key,
}),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', card: '#181818', inset: '#202020', cardHover: '#222' },
text: { primary: '#fff', muted: '#999', onAccent: '#000' },
border: { default: '#444', strong: '#666' },
accent: { main: '#fc0' },
tag: { red: '#f66' },
},
}),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: () => ({ palette: d3roNativeDarkPalette }),
}
})
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({ authError: null }),
}))

View file

@ -18,6 +18,7 @@ const mockStreamTalkResponse = jest.fn()
jest.mock('@react-navigation/native', () => {
const ReactValue = require('react') as typeof React
return {
useNavigation: () => ({ goBack: jest.fn() }),
useFocusEffect: (effect: () => void | (() => void)) => {
ReactValue.useEffect(effect, [effect])
},

View file

@ -7,7 +7,10 @@ const mockSelectTemplate = jest.fn()
const mockSearchMemos = jest.fn()
const mockAddMemoTag = jest.fn()
jest.mock('@react-navigation/native', () => ({ useIsFocused: () => true }))
jest.mock('@react-navigation/native', () => ({
useIsFocused: () => true,
useNavigation: () => ({ goBack: jest.fn() }),
}))
jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) }))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({

View file

@ -0,0 +1,48 @@
import { RAW, STATUS } from '../../../packages/ui/src/theme-raw'
import {
D3RO_NATIVE_RAW,
D3RO_NATIVE_STATUS,
getD3roNativePalette,
} from '@d3ro/ui-native'
type Rgb = readonly [number, number, number]
function rgb(hex: string): Rgb {
const normalized = hex.replace('#', '')
if (!/^[0-9a-f]{6}$/i.test(normalized)) throw new Error(`Expected 6-digit hex, got ${hex}`)
return [0, 2, 4].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as unknown as Rgb
}
function luminance(hex: string): number {
const channels = rgb(hex).map((value) => {
const channel = value / 255
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
})
return (0.2126 * channels[0]!) + (0.7152 * channels[1]!) + (0.0722 * channels[2]!)
}
function contrast(foreground: string, background: string): number {
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
return (lighter! + 0.05) / (darker! + 0.05)
}
describe('mobile v4 theme contract', () => {
test.each(['dark', 'light'] as const)('%s raw tokens stay in desktop SSOT parity', (mode) => {
for (const group of ['bg', 'text', 'border', 'accent'] as const) {
for (const [key, value] of Object.entries(D3RO_NATIVE_RAW[mode][group])) {
expect(RAW[mode][group][key as keyof typeof RAW[typeof mode][typeof group]]).toBe(value)
}
}
expect(D3RO_NATIVE_STATUS[mode]).toEqual(STATUS[mode])
})
test.each(['dark', 'light'] as const)('%s readable roles meet 4.5:1 contrast', (mode) => {
const palette = getD3roNativePalette(mode)
for (const background of [palette.bg.app, palette.bg.card, palette.bg.elevated, palette.bg.sidebar]) {
for (const foreground of [palette.text.primary, palette.text.secondary, palette.text.tertiary]) {
expect(contrast(foreground, background)).toBeGreaterThanOrEqual(4.5)
}
}
expect(contrast(palette.accent.ink, palette.accent.solid)).toBeGreaterThanOrEqual(4.5)
})
})

View file

@ -75,7 +75,10 @@ describe('ui-native runtime theme boundary', () => {
const darkButton = hostByTestId(renderer!.root, 'theme-button')
expect(StyleSheet.flatten(darkText.props.style).color).toBe(dark.text.primary)
expect(StyleSheet.flatten(darkCard.props.style).backgroundColor).toBe(dark.bg.card)
expect(StyleSheet.flatten(darkButton.props.style).backgroundColor).toBe(dark.accent.main)
expect(StyleSheet.flatten(darkButton.props.style).backgroundColor).toBe(dark.accent.solid)
expect(StyleSheet.flatten(darkButton.props.style).minHeight).toBe(48)
expect(StyleSheet.flatten(darkCard.props.style).borderWidth).toBe(1)
expect(StyleSheet.flatten(darkCard.props.style).shadowOpacity).toBeUndefined()
act(() => {
renderer!.update(<ThemeFixture mode="light" />)
@ -87,7 +90,7 @@ describe('ui-native runtime theme boundary', () => {
const lightButton = hostByTestId(renderer!.root, 'theme-button')
expect(StyleSheet.flatten(lightText.props.style).color).toBe(light.text.primary)
expect(StyleSheet.flatten(lightCard.props.style).backgroundColor).toBe(light.bg.card)
expect(StyleSheet.flatten(lightButton.props.style).backgroundColor).toBe(light.accent.main)
expect(StyleSheet.flatten(lightButton.props.style).backgroundColor).toBe(light.accent.solid)
act(() => renderer!.unmount())
})

View file

@ -0,0 +1,47 @@
import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { UnifiedSearchResult } from '@d3ro/core/types'
import { NativeThemeProvider, getD3roNativePalette } from '@d3ro/ui-native'
import { UnifiedSearchResultRow } from '../src/components/UnifiedSearchResultRow'
const result: UnifiedSearchResult = {
source: 'meeting',
id: '33333333-3333-4333-8333-333333333333',
title: '주간 회의',
excerpt: '로드맵을 다음 주에 검토합니다.',
matchedField: 'transcript',
occurredAt: Date.parse('2026-09-28T00:00:00.000Z'),
}
describe('UnifiedSearchResultRow', () => {
test('exposes a named button and forwards the source result', () => {
const onPress = jest.fn()
let renderer: ReactTestRenderer | undefined
act(() => {
renderer = create(
<NativeThemeProvider palette={getD3roNativePalette('dark')}>
<UnifiedSearchResultRow
result={result}
title="주간 회의"
sourceLabel="회의"
matchLabel="전사 일치"
dateLabel="2026. 9. 28."
previewFallback="미리 볼 내용이 없습니다."
accessibilityLabel="회의 ‘주간 회의’ 열기"
onPress={onPress}
/>
</NativeThemeProvider>,
)
})
const button = renderer!.root.findByProps({
testID: `unified-search-open-${result.source}-${result.id}`,
})
expect(button.props.accessibilityRole).toBe('button')
expect(button.props.accessibilityLabel).toBe('회의 ‘주간 회의’ 열기')
act(() => button.props.onPress())
expect(onPress).toHaveBeenCalledWith(result)
act(() => renderer!.unmount())
})
})

View file

@ -0,0 +1,215 @@
jest.mock('../src/lib/supabase', () => ({ supabase: {} }))
import type { D3roSupabaseClient, HistoryEntry, Meeting } from '@d3ro/api-client'
import { normalizeUnifiedSearchInitialQuery } from '../src/features/search/unified-search-route'
import {
createMobileSearchExcerpt,
createPostgrestLikePattern,
createSupabaseUnifiedSearchSource,
sanitizeUnifiedSearchQuery,
searchUnifiedContent,
type MobileUnifiedSearchOptions,
type UnifiedSearchSourcePort,
} from '../src/features/search/unified-search-service'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const record: HistoryEntry = {
id: '22222222-2222-4222-8222-222222222222',
user_id: USER_ID,
title: '메모',
original_text: '초기 로드맵을 기록했습니다.',
polished_text: '다음 분기 로드맵을 기록했습니다.',
focused_app: null,
focused_app_name: null,
focused_app_window_title: null,
mode: 'dictation',
status: 'completed',
error_code: null,
audio_storage_key: null,
duration: 12,
detected_language: 'ko',
mic_device: null,
word_count: 5,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
app_version: '1.9.0',
summary_text: null,
is_favorite: false,
revision: 1,
created_at: '2026-09-27T00:00:00.000Z',
updated_at: '2026-09-27T00:00:00.000Z',
}
const meeting: Meeting = {
id: '33333333-3333-4333-8333-333333333333',
user_id: USER_ID,
team_id: null,
title: '주간 회의',
status: 'completed',
started_at: '2026-09-28T00:00:00.000Z',
ended_at: '2026-09-28T00:30:00.000Z',
duration_ms: 1_800_000,
raw_transcript: '로드맵을 다음 주에 검토합니다.',
edited_transcript: null,
minutes_markdown: null,
minutes_json: null,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
error_message: null,
audio_storage_key: null,
created_at: '2026-09-28T00:00:00.000Z',
updated_at: '2026-09-28T00:30:00.000Z',
}
describe('mobile unified search service', () => {
test('sanitizes PostgREST control characters and bounds excerpts', () => {
expect(sanitizeUnifiedSearchQuery(' Q3.1, foo_bar 50% ("초안") O\'Reilly ')).toBe(
"Q3.1 foo_bar 50% 초안 O'Reilly",
)
expect(createPostgrestLikePattern('Q3.1')).toBe('*Q3.1*')
expect(createPostgrestLikePattern('foo_bar')).toBe('*foo\\_bar*')
expect(createPostgrestLikePattern('50%')).toBe('*50\\%*')
expect(createPostgrestLikePattern('literal*star')).toBe('*literal\\*star*')
const excerpt = createMobileSearchExcerpt(
`앞 ${'가'.repeat(80)} 로드맵 ${'나'.repeat(80)}`,
'로드맵',
60,
)
expect(excerpt).toContain('로드맵')
expect(excerpt.startsWith('…')).toBe(true)
expect(excerpt.endsWith('…')).toBe(true)
})
test('searches both sources, reports full totals, and opens newest source first', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(async () => ({ rows: [record], total: 4 })),
searchMeetings: jest.fn(async () => ({ rows: [meeting], total: 3 })),
}
const response = await searchUnifiedContent({
userId: USER_ID,
query: ' 로드맵 ',
limit: 10,
}, source)
expect(source.searchRecords).toHaveBeenCalledWith(USER_ID, '로드맵', 10)
expect(source.searchMeetings).toHaveBeenCalledWith('로드맵', 10)
expect(response.total).toBe(7)
expect(response.results.map(({ source: kind, id }) => ({ kind, id }))).toEqual([
{ kind: 'meeting', id: meeting.id },
{ kind: 'record', id: record.id },
])
expect(response.results[0]).toMatchObject({ matchedField: 'transcript' })
expect(response.results[1]).toMatchObject({ matchedField: 'content' })
})
test('does not query either table for blank input', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
await expect(searchUnifiedContent({ userId: USER_ID, query: ' ' }, source)).resolves.toEqual({
query: '',
results: [],
total: 0,
})
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('rejects a missing authentication boundary before querying', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
const invalidUserIds = ['not-a-user', 42, null]
for (const userId of invalidUserIds) {
const options = { userId, query: '로드맵' } as unknown as MobileUnifiedSearchOptions
await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({
code: 'auth',
})
}
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('rejects malformed options, queries, and non-finite limits before querying', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
const malformedOptions: MobileUnifiedSearchOptions[] = [
null as unknown as MobileUnifiedSearchOptions,
{ userId: USER_ID, query: 42 } as unknown as MobileUnifiedSearchOptions,
{ userId: USER_ID, query: '로드맵', limit: Number.NaN },
{ userId: USER_ID, query: '로드맵', limit: Number.POSITIVE_INFINITY },
]
for (const options of malformedOptions) {
await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({ code: 'invalid' })
}
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('escapes a raw PostgREST wildcard instead of creating a broad match', () => {
expect(createPostgrestLikePattern('*')).toBe('*\\**')
expect(createPostgrestLikePattern('*')).not.toBe('***')
})
test('uses minimal Supabase projections and leaves team meeting visibility to RLS', async () => {
const recordBuilder = {
select: jest.fn(),
eq: jest.fn(),
or: jest.fn(),
order: jest.fn(),
limit: jest.fn(),
}
const meetingBuilder = {
select: jest.fn(),
eq: jest.fn(),
or: jest.fn(),
order: jest.fn(),
limit: jest.fn(),
}
for (const builder of [recordBuilder, meetingBuilder]) {
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
builder.or.mockReturnValue(builder)
builder.order.mockReturnValue(builder)
builder.limit.mockResolvedValue({ data: [], error: null, count: 0 })
}
const from = jest.fn((table: string) => (
table === 'history' ? recordBuilder : meetingBuilder
))
const source = createSupabaseUnifiedSearchSource({ from } as unknown as D3roSupabaseClient)
await source.searchRecords(USER_ID, '로드맵', 10)
await source.searchMeetings('*', 10)
expect(recordBuilder.select).toHaveBeenCalledWith(
'id,title,original_text,polished_text,summary_text,created_at',
{ count: 'exact' },
)
expect(recordBuilder.eq).toHaveBeenCalledWith('user_id', USER_ID)
expect(meetingBuilder.select).toHaveBeenCalledWith(
'id,title,raw_transcript,edited_transcript,started_at',
{ count: 'exact' },
)
expect(meetingBuilder.eq).not.toHaveBeenCalled()
expect(meetingBuilder.or).toHaveBeenCalledWith(
'title.ilike.*\\**,raw_transcript.ilike.*\\**,edited_transcript.ilike.*\\**',
)
})
test('guards restored navigation initialQuery values at runtime', () => {
expect(normalizeUnifiedSearchInitialQuery({ unexpected: true })).toBe('')
expect(normalizeUnifiedSearchInitialQuery(123)).toBe('')
expect(normalizeUnifiedSearchInitialQuery('가'.repeat(120))).toHaveLength(100)
})
})

View file

@ -7,11 +7,13 @@ import {
import { useMobileAds } from '../lib/mobile-ads-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeText } from '../theme/themed-components'
import { useI18n } from '@d3ro/i18n'
const INITIAL_RETRY_DELAY_MS = 30_000
const MAX_RETRY_DELAY_MS = 5 * 60_000
export default function FreeTierBanner(): React.ReactElement | null {
const { t } = useI18n()
const ads = useMobileAds()
const { palette } = useMobilePreferences()
const [loaded, setLoaded] = useState(false)
@ -53,7 +55,7 @@ export default function FreeTierBanner(): React.ReactElement | null {
},
]}
testID="free-tier-ad-slot"
accessibilityLabel="Sponsored"
accessibilityLabel={t('mobile.ads.sponsored')}
>
{ads.bannerUnitId && !failed ? (
<BannerAd
@ -73,12 +75,16 @@ export default function FreeTierBanner(): React.ReactElement | null {
/>
) : (
<ThemeText variant="label" color="muted" style={styles.statusText}>
{failed ? 'AD UNAVAILABLE' : ads.status === 'ready' ? 'AD LOADING' : 'AD CONSENT'}
{failed
? t('mobile.ads.unavailable')
: ads.status === 'ready'
? t('mobile.ads.loading')
: t('mobile.ads.consent')}
</ThemeText>
)}
{ads.bannerUnitId && !loaded && !failed && (
<ThemeText variant="label" color="muted" style={styles.loadingOverlay}>
AD LOADING
{t('mobile.ads.loading')}
</ThemeText>
)}
</View>
@ -93,6 +99,6 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
overflow: 'hidden',
},
statusText: { fontSize: 9, letterSpacing: 1.2 },
loadingOverlay: { position: 'absolute', fontSize: 9, letterSpacing: 1.2 },
statusText: { fontSize: 11, letterSpacing: 0.6 },
loadingOverlay: { position: 'absolute', fontSize: 11, letterSpacing: 0.6 },
})

View file

@ -0,0 +1,348 @@
// src/components/RecordFab.tsx — 떠 있는 녹음 버튼 + 위로 펼쳐지는 시작 메뉴 (M3 FAB menu).
// 탭 바는 이동용, 녹음 시작은 이 버튼 하나가 맡는다(design.md 2026-09-28 모바일 하단).
import { useCallback, useEffect, useRef, useState } from 'react'
import {
AccessibilityInfo,
Animated,
BackHandler,
Keyboard,
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeText } from '../theme/themed-components'
export const RECORD_FAB_SIZE = 56
export const RECORD_FAB_GAP = 16
/** 탭 화면 스크롤 끝이 FAB 에 가리지 않도록 더하는 여백(버튼 + 위아래 간격). */
export const RECORD_FAB_CLEARANCE = RECORD_FAB_SIZE + RECORD_FAB_GAP * 2
const MENU_ANIMATION_MS = 160
export type RecordFabAction = 'dictation' | 'meeting' | 'import'
const MENU_ITEMS: ReadonlyArray<{
action: RecordFabAction
labelKey: TranslationKey
hintKey: TranslationKey
}> = [
{ action: 'dictation', labelKey: 'mobile.fab.dictation', hintKey: 'mobile.fab.dictationHint' },
{ action: 'meeting', labelKey: 'mobile.fab.meeting', hintKey: 'mobile.fab.meetingHint' },
{ action: 'import', labelKey: 'mobile.fab.import', hintKey: 'mobile.fab.importHint' },
]
type Palette = ReturnType<typeof useMobilePreferences>['palette']
function useReduceMotion(): boolean {
const [reduceMotion, setReduceMotion] = useState(false)
useEffect(() => {
let active = true
void AccessibilityInfo.isReduceMotionEnabled()
.then((enabled) => { if (active) setReduceMotion(enabled) })
.catch(() => undefined)
const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion)
return () => {
active = false
subscription.remove()
}
}, [])
return reduceMotion
}
function useKeyboardVisible(): boolean {
const [visible, setVisible] = useState(false)
useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setVisible(true))
const hide = Keyboard.addListener('keyboardDidHide', () => setVisible(false))
return () => {
show.remove()
hide.remove()
}
}, [])
return visible
}
export interface RecordFabProps {
/** 홈·기록·회의 탭에서만 true. false 가 되면 열린 메뉴도 닫힌다. */
visible: boolean
/** 화면 아래 끝에서 FAB 아래 가장자리까지의 거리(탭 바 높이 + 간격). */
bottomOffset: number
onSelect: (action: RecordFabAction) => void
style?: StyleProp<ViewStyle>
}
/**
* 전체 화면을 덮는 층(pointerEvents box-none) 위에 FAB 를 오른쪽 아래에 띄운다.
* 열리면 스크림 + 메뉴 3개. 스크림·Android 백 키·항목 선택으로 닫힌다.
*/
export default function RecordFab({
visible,
bottomOffset,
onSelect,
style,
}: RecordFabProps): React.ReactElement | null {
const { t } = useI18n()
const { palette } = useMobilePreferences()
const reduceMotion = useReduceMotion()
const keyboardVisible = useKeyboardVisible()
const [open, setOpen] = useState(false)
const progress = useRef(new Animated.Value(0)).current
const shown = visible && !keyboardVisible
const close = useCallback((): void => setOpen(false), [])
useEffect(() => {
if (!shown) setOpen(false)
}, [shown])
useEffect(() => {
if (reduceMotion) {
progress.stopAnimation()
progress.setValue(open ? 1 : 0)
return
}
Animated.timing(progress, {
toValue: open ? 1 : 0,
duration: MENU_ANIMATION_MS,
useNativeDriver: true,
}).start()
}, [open, progress, reduceMotion])
useEffect(() => {
if (!open) return undefined
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
setOpen(false)
return true
})
return () => subscription.remove()
}, [open])
if (!shown) return null
const select = (action: RecordFabAction): void => {
setOpen(false)
onSelect(action)
}
const menuTranslate = progress.interpolate({ inputRange: [0, 1], outputRange: [8, 0] })
return (
<View
style={[StyleSheet.absoluteFill, styles.layer, style]}
pointerEvents="box-none"
accessibilityViewIsModal={open}
testID="record-fab-layer"
>
{open && (
<Animated.View style={[StyleSheet.absoluteFill, { opacity: progress }]}>
<Pressable
style={[StyleSheet.absoluteFill, { backgroundColor: palette.scrim }]}
onPress={close}
accessibilityRole="button"
accessibilityLabel={t('mobile.fab.close')}
testID="record-fab-scrim"
/>
</Animated.View>
)}
<View
style={[styles.anchor, { bottom: bottomOffset }]}
pointerEvents="box-none"
>
{open && (
<Animated.View
style={[
styles.menu,
{ opacity: progress, transform: [{ translateY: menuTranslate }] },
]}
testID="record-fab-menu"
>
{MENU_ITEMS.map((item) => (
<Pressable
key={item.action}
accessibilityRole="button"
accessibilityLabel={t(item.labelKey)}
accessibilityHint={t(item.hintKey)}
onPress={() => select(item.action)}
testID={`record-fab-${item.action}`}
style={({ pressed }) => [
styles.menuItem,
floatingShadow(palette),
{
backgroundColor: pressed ? palette.bg.cardHover : palette.bg.elevated,
borderColor: palette.border.default,
},
]}
>
<MenuGlyph action={item.action} color={palette.text.primary} />
<ThemeText variant="body" color="primary" style={styles.menuLabel}>
{t(item.labelKey)}
</ThemeText>
</Pressable>
))}
</Animated.View>
)}
<Pressable
accessibilityRole="button"
accessibilityLabel={open ? t('mobile.fab.close') : t('mobile.fab.open')}
accessibilityState={{ expanded: open }}
onPress={() => setOpen((current) => !current)}
testID="record-fab"
style={({ pressed }) => [
styles.fab,
floatingShadow(palette),
{ backgroundColor: pressed ? palette.accent.solidHover : palette.accent.solid },
]}
>
{open
? <CloseGlyph color={palette.accent.ink} />
: <MicGlyph color={palette.accent.ink} />}
</Pressable>
</View>
</View>
)
}
/** 떠 있는 층 1단계 그림자(글로우 아님). */
function floatingShadow(palette: Palette): ViewStyle {
return {
shadowColor: palette.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.18,
shadowRadius: 6,
elevation: 4,
}
}
function MicGlyph({ color }: { color: string }): React.ReactElement {
return (
<View style={glyph.box}>
<View style={[glyph.micHead, { borderColor: color }]} />
<View style={[glyph.micCup, { borderColor: color }]} />
<View style={[glyph.micStem, { backgroundColor: color }]} />
</View>
)
}
function CloseGlyph({ color }: { color: string }): React.ReactElement {
return (
<View style={glyph.box}>
<View style={[glyph.crossBar, { backgroundColor: color, transform: [{ rotate: '45deg' }] }]} />
<View style={[glyph.crossBar, { backgroundColor: color, transform: [{ rotate: '-45deg' }] }]} />
</View>
)
}
function MenuGlyph({ action, color }: { action: RecordFabAction; color: string }): React.ReactElement {
if (action === 'dictation') return <MicGlyph color={color} />
if (action === 'meeting') {
return (
<View style={glyph.box}>
<View style={glyph.peopleRow}>
<View style={[glyph.head, { borderColor: color }]} />
<View style={[glyph.head, { borderColor: color }]} />
</View>
<View style={glyph.peopleRow}>
<View style={[glyph.shoulders, { borderColor: color }]} />
<View style={[glyph.shoulders, { borderColor: color }]} />
</View>
</View>
)
}
return (
<View style={glyph.box}>
<View style={[glyph.file, { borderColor: color }]}>
<View style={[glyph.arrowShaft, { backgroundColor: color }]} />
<View style={[glyph.arrowHead, { borderColor: color }]} />
</View>
</View>
)
}
const styles = StyleSheet.create({
// The custom tab bar lives beside the active scene. On compact screens the
// scene can otherwise paint over the FAB even though its accessibility node
// remains tappable. Keep the whole interaction layer above the scene.
layer: {
zIndex: 20,
elevation: 20,
},
anchor: {
position: 'absolute',
right: RECORD_FAB_GAP,
alignItems: 'flex-end',
},
menu: {
alignItems: 'flex-end',
gap: 8,
marginBottom: 12,
},
menuItem: {
minHeight: 48,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 10,
borderWidth: 1,
borderRadius: 8,
},
menuLabel: { fontWeight: '500' },
fab: {
width: RECORD_FAB_SIZE,
height: RECORD_FAB_SIZE,
borderRadius: RECORD_FAB_SIZE / 2,
alignItems: 'center',
justifyContent: 'center',
},
})
const STROKE = 1.5
const glyph = StyleSheet.create({
box: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' },
micHead: { width: 8, height: 12, borderWidth: STROKE, borderRadius: 4 },
micCup: {
width: 14,
height: 7,
marginTop: -4,
borderWidth: STROKE,
borderTopWidth: 0,
borderBottomLeftRadius: 7,
borderBottomRightRadius: 7,
},
micStem: { width: STROKE, height: 3 },
crossBar: { position: 'absolute', width: 18, height: STROKE, borderRadius: 1 },
peopleRow: { flexDirection: 'row', gap: 2 },
head: { width: 7, height: 7, borderWidth: STROKE, borderRadius: 4, marginHorizontal: 1 },
shoulders: {
width: 9,
height: 6,
marginTop: 1,
borderWidth: STROKE,
borderBottomWidth: 0,
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
},
file: {
width: 16,
height: 20,
borderWidth: STROKE,
borderRadius: 3,
alignItems: 'center',
justifyContent: 'center',
},
arrowShaft: { width: STROKE, height: 9 },
arrowHead: {
width: 6,
height: 6,
marginTop: -6,
borderRightWidth: STROKE,
borderBottomWidth: STROKE,
transform: [{ rotate: '45deg' }],
},
})

View file

@ -0,0 +1,137 @@
import type { ReactNode } from 'react'
import {
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useI18n } from '@d3ro/i18n'
import { d3roNativeControl, d3roNativeRadius } from '@d3ro/ui-native'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeText } from '../theme/themed-components'
export interface ScreenHeaderProps {
title: string
subtitle?: string
/** Root-stack screens show a back action. Tab roots explicitly set false. */
showBack?: boolean
onBack?: () => void
rightContent?: ReactNode
style?: StyleProp<ViewStyle>
testID?: string
}
export function ScreenHeader({
title,
subtitle,
showBack = true,
onBack,
rightContent,
style,
testID,
}: ScreenHeaderProps): React.ReactElement {
const navigation = useNavigation()
const insets = useSafeAreaInsets()
const { t } = useI18n()
const { palette } = useMobilePreferences()
return (
<View
testID={testID}
style={[
styles.container,
{
paddingTop: insets.top,
backgroundColor: palette.bg.sidebar,
borderBottomColor: palette.border.default,
},
style,
]}
>
<View style={styles.row}>
{showBack && (
<Pressable
accessibilityRole="button"
accessibilityLabel={t('common.back')}
hitSlop={2}
onPress={onBack ?? (() => navigation.goBack())}
style={({ pressed }) => [
styles.backButton,
pressed && { backgroundColor: palette.overlay.soft },
]}
>
<ThemeText
color="primary"
style={styles.backGlyph}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
‹
</ThemeText>
</Pressable>
)}
<View style={styles.copy}>
<ThemeText
accessibilityRole="header"
variant="title"
color="primary"
numberOfLines={1}
>
{title}
</ThemeText>
{subtitle !== undefined && (
<ThemeText variant="small" color="tertiary" numberOfLines={2}>
{subtitle}
</ThemeText>
)}
</View>
{rightContent !== undefined && (
<View style={styles.right}>{rightContent}</View>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
borderBottomWidth: 1,
},
row: {
minHeight: 64,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 16,
paddingVertical: 8,
},
backButton: {
width: d3roNativeControl.h,
height: d3roNativeControl.h,
alignItems: 'center',
justifyContent: 'center',
borderRadius: d3roNativeRadius.button,
},
backGlyph: {
fontSize: 32,
lineHeight: 34,
fontWeight: '400',
},
copy: {
flex: 1,
minWidth: 0,
gap: 2,
},
right: {
minHeight: d3roNativeControl.h,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 8,
},
})

View file

@ -0,0 +1,92 @@
import { Pressable, StyleSheet, View } from 'react-native'
import type { UnifiedSearchResult } from '@d3ro/core/types'
import {
MetalCard,
PhosphorText,
d3roNativeControl,
useNativePalette,
} from '@d3ro/ui-native'
interface UnifiedSearchResultRowProps {
result: UnifiedSearchResult
title: string
sourceLabel: string
matchLabel: string
dateLabel: string
previewFallback: string
accessibilityLabel: string
onPress: (result: UnifiedSearchResult) => void
}
export function UnifiedSearchResultRow({
result,
title,
sourceLabel,
matchLabel,
dateLabel,
previewFallback,
accessibilityLabel,
onPress,
}: UnifiedSearchResultRowProps): React.ReactElement {
const palette = useNativePalette()
return (
<MetalCard style={styles.card} testID={`unified-search-result-${result.source}-${result.id}`}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={() => onPress(result)}
style={({ pressed }) => [
styles.pressable,
pressed && { backgroundColor: palette.overlay.soft },
]}
testID={`unified-search-open-${result.source}-${result.id}`}
>
<View style={styles.headingRow}>
<PhosphorText variant="heading" color="primary" numberOfLines={2} style={styles.title}>
{title}
</PhosphorText>
<PhosphorText variant="meta" color="tertiary" style={styles.date}>
{dateLabel}
</PhosphorText>
</View>
<View style={styles.metaRow}>
<PhosphorText variant="label" color="accent">{sourceLabel}</PhosphorText>
<PhosphorText variant="label" color="tertiary">{matchLabel}</PhosphorText>
</View>
<PhosphorText variant="body" color="secondary" numberOfLines={3}>
{result.excerpt || previewFallback}
</PhosphorText>
</Pressable>
</MetalCard>
)
}
const styles = StyleSheet.create({
card: {
padding: 0,
marginBottom: 12,
},
pressable: {
minHeight: d3roNativeControl.h,
padding: 16,
gap: 8,
},
headingRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
title: {
flex: 1,
},
date: {
flexShrink: 0,
fontVariant: ['tabular-nums'],
},
metaRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
})

View file

@ -0,0 +1,5 @@
const MAX_INITIAL_QUERY_LENGTH = 100
export function normalizeUnifiedSearchInitialQuery(value: unknown): string {
return typeof value === 'string' ? value.slice(0, MAX_INITIAL_QUERY_LENGTH) : ''
}

View file

@ -0,0 +1,272 @@
import type { D3roSupabaseClient, HistoryEntry, Meeting } from '@d3ro/api-client'
import type {
UnifiedSearchResponse,
UnifiedSearchResult,
} from '@d3ro/core/types'
import { supabase } from '../../lib/supabase'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const DEFAULT_LIMIT = 30
const MAX_LIMIT = 50
const MAX_QUERY_LENGTH = 100
const EXCERPT_LENGTH = 180
const HISTORY_SEARCH_COLUMNS = 'id,title,original_text,polished_text,summary_text,created_at'
const MEETING_SEARCH_COLUMNS = 'id,title,raw_transcript,edited_transcript,started_at'
export type UnifiedSearchServiceErrorCode = 'auth' | 'invalid' | 'network' | 'server'
export class UnifiedSearchServiceError extends Error {
constructor(
public readonly code: UnifiedSearchServiceErrorCode,
message: string,
) {
super(message)
this.name = 'UnifiedSearchServiceError'
}
}
export interface UnifiedSearchSourcePage<T> {
rows: T[]
total: number
}
type UnifiedSearchHistoryRow = Pick<
HistoryEntry,
'id' | 'title' | 'original_text' | 'polished_text' | 'summary_text' | 'created_at'
>
type UnifiedSearchMeetingRow = Pick<
Meeting,
'id' | 'title' | 'raw_transcript' | 'edited_transcript' | 'started_at'
>
export interface UnifiedSearchSourcePort {
searchRecords: (
userId: string,
query: string,
limit: number,
) => Promise<UnifiedSearchSourcePage<UnifiedSearchHistoryRow>>
searchMeetings: (
query: string,
limit: number,
) => Promise<UnifiedSearchSourcePage<UnifiedSearchMeetingRow>>
}
export interface MobileUnifiedSearchOptions {
userId: string
query: string
limit?: number
}
function client(): D3roSupabaseClient {
return supabase as unknown as D3roSupabaseClient
}
function requireOptions(options: MobileUnifiedSearchOptions): void {
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
throw new UnifiedSearchServiceError('invalid', 'Unified search options are required')
}
}
function requireUserId(userId: unknown): asserts userId is string {
if (typeof userId !== 'string' || !UUID_PATTERN.test(userId)) {
throw new UnifiedSearchServiceError('auth', 'A valid authenticated user is required')
}
}
function requireQuery(query: unknown): asserts query is string {
if (typeof query !== 'string') {
throw new UnifiedSearchServiceError('invalid', 'Unified search query must be a string')
}
}
function normalizeLimit(limit: unknown): number {
if (limit === undefined) return DEFAULT_LIMIT
if (typeof limit !== 'number' || !Number.isFinite(limit)) {
throw new UnifiedSearchServiceError('invalid', 'Unified search limit must be a finite number')
}
return Math.max(1, Math.min(Math.floor(limit), MAX_LIMIT))
}
export function sanitizeUnifiedSearchQuery(value: string): string {
return value
.slice(0, MAX_QUERY_LENGTH)
.trim()
.replace(/[\u0000-\u001f\u007f,()"]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export function createPostgrestLikePattern(query: string): string {
const escaped = query.replace(/[\\%_*]/g, (character) => `\\${character}`)
return `*${escaped}*`
}
function includesQuery(value: string | null | undefined, query: string): boolean {
return value?.toLocaleLowerCase().includes(query.toLocaleLowerCase()) === true
}
export function createMobileSearchExcerpt(
value: string | null | undefined,
query: string,
maxLength = EXCERPT_LENGTH,
): string {
const text = (value ?? '').replace(/\s+/g, ' ').trim()
if (text.length <= maxLength) return text
const matchAt = text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase())
if (matchAt < 0) return `${text.slice(0, maxLength - 1).trimEnd()}…`
const context = Math.max(20, Math.floor((maxLength - query.length) / 2))
const start = Math.max(0, matchAt - context)
const end = Math.min(text.length, start + maxLength)
const excerpt = text.slice(start, end).trim()
return `${start > 0 ? '…' : ''}${excerpt}${end < text.length ? '…' : ''}`
}
function occurredAt(value: string): number {
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) ? timestamp : 0
}
function recordResult(row: UnifiedSearchHistoryRow, query: string): UnifiedSearchResult {
const titleMatched = includesQuery(row.title, query)
const matchingContent = [row.polished_text, row.original_text, row.summary_text]
.find((value) => includesQuery(value, query))
const preview = matchingContent
?? row.polished_text
?? row.original_text
?? row.summary_text
?? row.title
return {
source: 'record',
id: row.id,
title: row.title,
excerpt: createMobileSearchExcerpt(preview, query),
matchedField: titleMatched ? 'title' : 'content',
occurredAt: occurredAt(row.created_at),
}
}
function meetingResult(row: UnifiedSearchMeetingRow, query: string): UnifiedSearchResult {
const titleMatched = includesQuery(row.title, query)
const matchingTranscript = [row.edited_transcript, row.raw_transcript]
.find((value) => includesQuery(value, query))
const preview = matchingTranscript
?? row.edited_transcript
?? row.raw_transcript
?? row.title
return {
source: 'meeting',
id: row.id,
title: row.title,
excerpt: createMobileSearchExcerpt(preview, query),
matchedField: titleMatched ? 'title' : 'transcript',
occurredAt: occurredAt(row.started_at),
}
}
export function mergeMobileUnifiedSearchResults(
records: readonly UnifiedSearchResult[],
meetings: readonly UnifiedSearchResult[],
limit: number,
): UnifiedSearchResult[] {
return [...records, ...meetings]
.sort((left, right) => right.occurredAt - left.occurredAt || left.id.localeCompare(right.id))
.slice(0, limit)
}
function toSearchError(error: unknown): UnifiedSearchServiceError {
if (error instanceof UnifiedSearchServiceError) return error
const candidate = error as { code?: unknown; message?: unknown }
const code = typeof candidate?.code === 'string' ? candidate.code : ''
const message = typeof candidate?.message === 'string'
? candidate.message
: 'Unified search request failed'
const lower = message.toLocaleLowerCase()
if (
error instanceof TypeError
|| lower.includes('network request failed')
|| lower.includes('failed to fetch')
|| lower.includes('networkerror')
) {
return new UnifiedSearchServiceError('network', message)
}
if (code === 'PGRST301' || code === '42501' || lower.includes('jwt')) {
return new UnifiedSearchServiceError('auth', message)
}
return new UnifiedSearchServiceError('server', message)
}
export function createSupabaseUnifiedSearchSource(
searchClient: D3roSupabaseClient,
): UnifiedSearchSourcePort {
return {
async searchRecords(userId, query, limit) {
const pattern = createPostgrestLikePattern(query)
const { data, error, count } = await searchClient
.from('history')
.select(HISTORY_SEARCH_COLUMNS, { count: 'exact' })
.eq('user_id', userId)
.or(
`title.ilike.${pattern},original_text.ilike.${pattern},polished_text.ilike.${pattern},summary_text.ilike.${pattern}`,
)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit)
if (error !== null) throw error
return { rows: data ?? [], total: count ?? data?.length ?? 0 }
},
async searchMeetings(query, limit) {
const pattern = createPostgrestLikePattern(query)
const { data, error, count } = await searchClient
.from('meetings')
.select(MEETING_SEARCH_COLUMNS, { count: 'exact' })
.or(
`title.ilike.${pattern},raw_transcript.ilike.${pattern},edited_transcript.ilike.${pattern}`,
)
.order('started_at', { ascending: false })
.order('id', { ascending: false })
.limit(limit)
if (error !== null) throw error
return { rows: data ?? [], total: count ?? data?.length ?? 0 }
},
}
}
const supabaseSearchSource = createSupabaseUnifiedSearchSource(client())
export async function searchUnifiedContent(
options: MobileUnifiedSearchOptions,
source: UnifiedSearchSourcePort = supabaseSearchSource,
): Promise<UnifiedSearchResponse> {
requireOptions(options)
requireUserId(options.userId)
requireQuery(options.query)
const query = sanitizeUnifiedSearchQuery(options.query)
const limit = normalizeLimit(options.limit)
if (query.length === 0) return { query, results: [], total: 0 }
try {
const [recordPage, meetingPage] = await Promise.all([
source.searchRecords(options.userId, query, limit),
source.searchMeetings(query, limit),
])
const results = mergeMobileUnifiedSearchResults(
recordPage.rows.map((row) => recordResult(row, query)),
meetingPage.rows.map((row) => meetingResult(row, query)),
limit,
)
return {
query,
results,
total: recordPage.total + meetingPage.total,
}
} catch (error) {
throw toSearchError(error)
}
}

View file

@ -1,96 +1,166 @@
// src/navigation/TabNavigator.tsx — Bottom tabs: DASH / WORKS / REC / TALK / SET
import { View, StyleSheet } from 'react-native'
// src/navigation/TabNavigator.tsx — 하단 탭 4개(홈·기록·회의·더보기) + 떠 있는 녹음 버튼.
// 탭 바는 이동만 한다. 녹음 시작(받아쓰기·회의·파일)은 RecordFab 메뉴가 맡는다(design.md 2026-09-28).
import { useState } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import {
BottomTabBar,
createBottomTabNavigator,
type BottomTabBarProps,
} from '@react-navigation/bottom-tabs'
import DashScreen from '../screens/DashScreen'
import WorksHubScreen from '../screens/WorksHubScreen'
import { useI18n } from '@d3ro/i18n'
import HomeScreen from '../screens/HomeScreen'
import HistoryScreen from '../screens/HistoryScreen'
import MeetingsScreen from '../screens/MeetingsScreen'
import MoreScreen from '../screens/MoreScreen'
import RecordScreen from '../screens/RecordScreen'
import TalkScreen from '../screens/TalkScreen'
import SettingsScreen from '../screens/SettingsScreen'
import FreeTierBanner from '../components/FreeTierBanner'
import RecordFab, { RECORD_FAB_GAP, type RecordFabAction } from '../components/RecordFab'
import { useMobilePreferences } from '../lib/preferences-context'
export type MainTabParamList = {
Dash: undefined
Works: undefined
Home: undefined
Records: undefined
/** createRequest: 녹음 버튼 메뉴 "회의 녹음" — 값이 바뀔 때마다 새 회의 만들기 창을 한 번 연다. */
Meetings: { createRequest?: number } | undefined
More: undefined
/**
* 탭 바에는 보이지 않는 녹음 화면. 탭 안에 두어 녹음 중 다른 탭으로 가도 화면이
* 마운트된 채 남고, MeetingDetail·공유 인텐트의 기존 params 경로가 그대로 유지된다.
* importRequest: 녹음 버튼 메뉴 "오디오 파일 가져오기" — 값마다 한 번 가져오기를 시작한다.
*/
Record: {
meetingId?: string;
meetingTitle?: string;
meetingLanguage?: string;
incomingMediaId?: string;
importRequest?: number;
} | undefined
Talk: undefined
Settings: undefined
}
/** 녹음 버튼이 보이는 탭(1급 화면). 녹음 화면·더보기에서는 숨긴다. */
const FAB_TABS: ReadonlySet<keyof MainTabParamList> = new Set(['Home', 'Records', 'Meetings'])
const Tab = createBottomTabNavigator<MainTabParamList>()
function TabIcon({ type, color }: { type: string; color: string }): React.ReactElement {
if (type === 'dash') {
return (
<View style={[iconStyles.grid, { borderColor: color }]}>
{[0, 1, 2, 3].map((i) => (
<View key={i} style={[iconStyles.gridCell, { borderColor: color }]} />
))}
</View>
)
}
if (type === 'works') {
return (
<View style={[iconStyles.rows, { borderColor: color }]}>
{[0, 1, 2].map((i) => (
<View key={i} style={[iconStyles.rowsLine, { backgroundColor: color }]} />
))}
</View>
)
}
if (type === 'talk') {
return <View style={[iconStyles.chat, { borderColor: color }]} />
}
if (type === 'set') {
return <View style={[iconStyles.gear, { borderColor: color }]} />
}
return <View />
}
type TabIconType = 'home' | 'records' | 'meetings' | 'more'
function RecordFAB(): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<View
style={[
fabStyles.fab,
{
backgroundColor: palette.accent.main,
borderColor: palette.bg.app,
shadowColor: palette.accent.main,
},
]}
>
<View style={fabStyles.micIcon}>
<View style={[fabStyles.micBody, { backgroundColor: palette.bg.app }]} />
<View style={[fabStyles.micBase, { backgroundColor: palette.bg.app }]} />
function TabIcon({ type, color }: { type: TabIconType; color: string }): React.ReactElement {
if (type === 'home') {
return (
<View style={icon.box}>
<View style={[icon.roof, { borderColor: color }]} />
<View style={[icon.house, { borderColor: color }]} />
</View>
)
}
if (type === 'records') {
return (
<View style={icon.box}>
<View style={[icon.page, { borderColor: color }]}>
<View style={[icon.line, { backgroundColor: color, width: 8 }]} />
<View style={[icon.line, { backgroundColor: color, width: 8 }]} />
<View style={[icon.line, { backgroundColor: color, width: 5 }]} />
</View>
</View>
)
}
if (type === 'meetings') {
return (
<View style={icon.box}>
<View style={icon.row}>
<View style={[icon.head, { borderColor: color }]} />
<View style={[icon.head, { borderColor: color }]} />
</View>
<View style={icon.row}>
<View style={[icon.shoulders, { borderColor: color }]} />
<View style={[icon.shoulders, { borderColor: color }]} />
</View>
</View>
)
}
return (
<View style={[icon.box, icon.dots]}>
{[0, 1, 2].map((index) => (
<View key={index} style={[icon.dot, { borderColor: color }]} />
))}
</View>
)
}
function FreeTierTabBar(props: BottomTabBarProps): React.ReactElement {
const { palette } = useMobilePreferences()
function TabLabel({
label,
color,
focused,
}: {
label: string
color: string
focused: boolean
}): React.ReactElement {
return (
<View style={{ backgroundColor: palette.bg.sidebar }}>
<FreeTierBanner />
<BottomTabBar {...props} />
</View>
<Text
style={[labelStyles.label, { color, fontWeight: focused ? '500' : '400' }]}
numberOfLines={1}
>
{label}
</Text>
)
}
function MainTabBar(props: BottomTabBarProps): React.ReactElement {
const { palette } = useMobilePreferences()
const [barHeight, setBarHeight] = useState<number | null>(null)
const { state, navigation, insets } = props
const focusedRoute = state.routes[state.index]?.name as keyof MainTabParamList | undefined
const onSelect = (action: RecordFabAction): void => {
if (action === 'dictation') navigation.navigate('Record')
else if (action === 'meeting') navigation.navigate('Meetings', { createRequest: Date.now() })
else navigation.navigate('Record', { importRequest: Date.now() })
}
// 측정 전에는 기본 탭 바 높이(49) + 하단 인셋으로 어림한다.
const measuredHeight = barHeight ?? insets.bottom + 49
return (
<>
<View
style={{ backgroundColor: palette.bg.sidebar }}
onLayout={(event) => setBarHeight(event.nativeEvent.layout.height)}
>
<FreeTierBanner />
<BottomTabBar {...props} />
</View>
<RecordFab
visible={focusedRoute !== undefined && FAB_TABS.has(focusedRoute)}
bottomOffset={measuredHeight + RECORD_FAB_GAP}
onSelect={onSelect}
/>
</>
)
}
export default function TabNavigator(): React.ReactElement {
const { palette } = useMobilePreferences()
const { t } = useI18n()
const tab = (labelKey: 'mobile.nav.home' | 'mobile.nav.records' | 'mobile.nav.meetings' | 'mobile.nav.more', type: TabIconType) => {
const label = t(labelKey)
return {
title: label,
tabBarAccessibilityLabel: label,
tabBarButtonTestID: `tab-${type}`,
tabBarLabel: ({ color, focused }: { color: string; focused: boolean }) => (
<TabLabel label={label} color={color} focused={focused} />
),
tabBarIcon: ({ color }: { color: string }) => <TabIcon type={type} color={color} />,
}
}
return (
<Tab.Navigator
tabBar={(props) => <FreeTierTabBar {...props} />}
tabBar={(props) => <MainTabBar {...props} />}
// 녹음 화면처럼 메뉴로 연 탭에서 백 키를 누르면 온 곳으로 돌아간다.
backBehavior="history"
screenOptions={{
// 높이/패딩은 react-navigation 기본값(세이프에어리어 자동 반영,
// 콘텐츠 수직 균형 포함)을 쓴다 — 커스텀 height 지정은 폴드 등
@ -102,92 +172,80 @@ export default function TabNavigator(): React.ReactElement {
},
tabBarActiveTintColor: palette.accent.main,
tabBarInactiveTintColor: palette.text.muted,
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
letterSpacing: 0.2,
},
headerShown: false,
}}
>
<Tab.Screen
name="Dash"
component={DashScreen}
options={{
title: 'DASH',
tabBarButtonTestID: 'tab-dash',
tabBarIcon: ({ color }) => <TabIcon type="dash" color={color} />,
}}
/>
<Tab.Screen
name="Works"
component={WorksHubScreen}
options={{
title: 'WORKS',
tabBarButtonTestID: 'tab-works',
tabBarIcon: ({ color }) => <TabIcon type="works" color={color} />,
}}
/>
<Tab.Screen name="Home" component={HomeScreen} options={tab('mobile.nav.home', 'home')} />
<Tab.Screen name="Records" component={HistoryScreen} options={tab('mobile.nav.records', 'records')} />
<Tab.Screen name="Meetings" component={MeetingsScreen} options={tab('mobile.nav.meetings', 'meetings')} />
<Tab.Screen name="More" component={MoreScreen} options={tab('mobile.nav.more', 'more')} />
<Tab.Screen
name="Record"
component={RecordScreen}
options={{
title: '',
tabBarButtonTestID: 'tab-record',
tabBarIcon: () => <RecordFAB />,
tabBarLabel: () => null,
}}
/>
<Tab.Screen
name="Talk"
component={TalkScreen}
options={{
title: 'TALK',
tabBarButtonTestID: 'tab-talk',
tabBarIcon: ({ color }) => <TabIcon type="talk" color={color} />,
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
title: 'SET',
tabBarButtonTestID: 'tab-settings',
tabBarIcon: ({ color }) => <TabIcon type="set" color={color} />,
title: t('mobile.fab.dictation'),
// 탭 바에서는 숨기고 경로만 남긴다 — 녹음은 떠 있는 버튼 메뉴로 연다.
tabBarButton: () => null,
tabBarItemStyle: styles.hiddenItem,
}}
/>
</Tab.Navigator>
)
}
const iconStyles = StyleSheet.create({
grid: { width: 22, height: 22, flexDirection: 'row', flexWrap: 'wrap', gap: 2 },
gridCell: { width: 9, height: 9, borderWidth: 1.5, borderRadius: 2 },
rows: {
width: 22, height: 22, borderWidth: 1.5, borderRadius: 5,
alignItems: 'center', justifyContent: 'center', gap: 2.5,
},
rowsLine: { width: 11, height: 1.5, borderRadius: 1 },
chat: { width: 22, height: 18, borderWidth: 1.5, borderRadius: 4 },
gear: { width: 22, height: 22, borderWidth: 1.5, borderRadius: 11 },
const styles = StyleSheet.create({
hiddenItem: { display: 'none' },
})
const fabStyles = StyleSheet.create({
fab: {
position: 'relative',
top: -20,
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 10,
elevation: 8,
borderWidth: 4,
},
micIcon: { alignItems: 'center' },
micBody: { width: 8, height: 14, borderRadius: 4, marginBottom: 2 },
micBase: { width: 14, height: 2, borderRadius: 1 },
const labelStyles = StyleSheet.create({
label: { fontSize: 12, lineHeight: 16, letterSpacing: 0 },
})
const STROKE = 1.5
const icon = StyleSheet.create({
box: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' },
roof: {
position: 'absolute',
top: 3,
width: 12,
height: 12,
borderTopWidth: STROKE,
borderLeftWidth: STROKE,
borderTopLeftRadius: 2,
transform: [{ rotate: '45deg' }],
},
house: {
position: 'absolute',
top: 9,
width: 14,
height: 12,
borderWidth: STROKE,
borderTopWidth: 0,
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
},
page: {
width: 16,
height: 20,
borderWidth: STROKE,
borderRadius: 3,
paddingLeft: 3,
justifyContent: 'center',
gap: 3,
},
line: { height: STROKE, borderRadius: 1 },
row: { flexDirection: 'row', gap: 2 },
head: { width: 7, height: 7, borderWidth: STROKE, borderRadius: 4, marginHorizontal: 1 },
shoulders: {
width: 9,
height: 6,
marginTop: 1,
borderWidth: STROKE,
borderBottomWidth: 0,
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
},
dots: { flexDirection: 'row', gap: 2 },
dot: { width: 6, height: 6, borderWidth: STROKE, borderRadius: 3 },
})

View file

@ -0,0 +1,22 @@
// src/navigation/tab-layout.ts — 탭 화면 스크롤 하단 여백 SSOT.
import { useContext } from 'react'
import { BottomTabBarHeightContext } from '@react-navigation/bottom-tabs'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { RECORD_FAB_CLEARANCE } from '../components/RecordFab'
/**
* 탭 화면 contentContainer 의 paddingBottom.
*
* bottom-tabs 는 탭 바를 화면 아래에 겹치지 않고 따로 배치하며(탭 바 높이에 insets.bottom 포함),
* 떠 있는 녹음 버튼만 내용 위에 겹친다. 그래서 탭 안에서는 버튼 여유만 더한다 — 탭 바 높이를
* 또 더하면 목록 끝에 탭 바만큼 빈 공간이 생긴다. 탭 밖(스택에서 연 경우·테스트)에서는
* 하단 인셋을 반영한다.
*/
export function useTabScreenBottomPadding({
avoidFab = true,
}: { avoidFab?: boolean } = {}): number {
const tabBarHeight = useContext(BottomTabBarHeightContext)
const insets = useSafeAreaInsets()
if (tabBarHeight === undefined) return insets.bottom + 24
return avoidFab ? RECORD_FAB_CLEARANCE : 24
}

View file

@ -14,9 +14,7 @@ import {
} from 'react-native'
import InAppBrowser from 'react-native-inappbrowser-reborn'
import { useNavigation } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
AppStatusBar,
Led,
MetalCard,
PhosphorText,
@ -47,6 +45,7 @@ import {
runAccountDeletion,
type AccountDeletionService,
} from '../features/account/account-deletion-service'
import { ScreenHeader } from '../components/ScreenHeader'
// Public surface kept for existing importers; the use case now lives in
// features/account so it is testable without rendering this screen.
@ -83,7 +82,6 @@ export default function AccountScreen({
deletionService = edgeAccountDeletionService,
identityService = identityManagementService,
}: AccountScreenProps): React.ReactElement {
const insets = useSafeAreaInsets()
const navigation = useNavigation<any>()
const { user, purgeLocalSession } = useAuth()
const { currentDevice } = useDevice()
@ -435,17 +433,17 @@ export default function AccountScreen({
if (!user) {
return (
<NativeThemeProvider palette={palette}>
<ScrollView
style={styles.container}
contentContainerStyle={[styles.emptyContent, { paddingTop: insets.top + 30 }]}
>
<MetalCard style={styles.emptyCard} accessibilityRole="alert">
<Led color="amber" size={9} />
<PhosphorText variant="body" color="secondary" style={styles.centeredText}>
{t('mobile.account.loginRequired')}
</PhosphorText>
</MetalCard>
</ScrollView>
<View style={styles.container}>
<ScreenHeader title={t('mobile.account.title')} testID="account-header" />
<ScrollView contentContainerStyle={styles.emptyContent}>
<MetalCard style={styles.emptyCard} accessibilityRole="alert">
<Led color="amber" size={9} />
<PhosphorText variant="body" color="secondary" style={styles.centeredText}>
{t('mobile.account.loginRequired')}
</PhosphorText>
</MetalCard>
</ScrollView>
</View>
</NativeThemeProvider>
)
}
@ -471,14 +469,7 @@ export default function AccountScreen({
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<PhosphorText
variant="title"
color="primary"
accessibilityRole="header"
style={[styles.screenTitle, { paddingTop: insets.top + 10 }]}
>
{t('mobile.account.title')}
</PhosphorText>
<ScreenHeader title={t('mobile.account.title')} testID="account-header" />
<MetalCard style={styles.identityCard}>
<View style={styles.avatar}>
@ -622,7 +613,13 @@ export default function AccountScreen({
/>
<InfoRow
label={t('mobile.account.tier')}
value={(profile?.tier ?? '—').toUpperCase()}
value={profile?.tier === 'free'
? t('mobile.paywall.free')
: profile?.tier === 'pro'
? t('mobile.paywall.pro')
: profile?.tier === 'pro_plus'
? t('mobile.paywall.proPlus')
: t('mobile.account.tierUnknown')}
last
/>
</MetalCard>
@ -770,10 +767,6 @@ export default function AccountScreen({
/>
</MetalCard>
<AppStatusBar
model={t('mobile.status.cloudModel')}
label={t('mobile.status.secureDataLink')}
/>
</ScrollView>
</NativeThemeProvider>
)
@ -966,7 +959,7 @@ function createStyles(palette: Palette) {
borderBottomColor: palette.border.subtle,
},
identityUnlinkButton: {
minHeight: 40,
minHeight: 48,
justifyContent: 'center',
paddingHorizontal: 10,
borderWidth: 1,

View file

@ -28,6 +28,7 @@ import {
import ContentReportSheet, {
type ContentReportTarget,
} from '../components/ContentReportSheet'
import { ScreenHeader } from '../components/ScreenHeader'
export default function ActionsScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
@ -174,29 +175,25 @@ export default function ActionsScreen(): React.ReactElement {
keyboardVerticalOffset={80}
testID="actions-screen"
>
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingTop: insets.top + 14, paddingBottom: insets.bottom + 110 },
]}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<View style={styles.headerCopy}>
<ThemeText variant="title" accessibilityRole="header">
{t('mobile.actions.title')}
</ThemeText>
<ThemeText variant="label" color="muted">
{t('mobile.actions.subtitle')}
</ThemeText>
</View>
<ScreenHeader
title={t('mobile.actions.title')}
subtitle={t('mobile.actions.subtitle')}
rightContent={(
<View style={styles.safetyBadge}>
<ThemeText variant="label" color="accent">
{t('mobile.actions.confirmBadge')}
</ThemeText>
</View>
</View>
)}
testID="actions-header"
/>
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 110 },
]}
keyboardShouldPersistTaps="handled"
>
<ThemeCard inset style={styles.contractCard} testID="actions-contract">
<ThemeText color="secondary" style={styles.contractText}>
{t('mobile.actions.contractNotice')}
@ -500,7 +497,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
},
headerCopy: { flex: 1, gap: 4 },
safetyBadge: {
minHeight: 34,
minHeight: 48,
justifyContent: 'center',
paddingHorizontal: 10,
borderRadius: 8,
@ -531,7 +528,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
reviewTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10 },
statusBadge: { minHeight: 30, justifyContent: 'center', paddingHorizontal: 9, borderWidth: 1, borderRadius: 999 },
reviewField: { gap: 5 },
reportButton: { minHeight: 44, alignSelf: 'flex-end', justifyContent: 'center', paddingHorizontal: 6 },
reportButton: { minHeight: 48, alignSelf: 'flex-end', justifyContent: 'center', paddingHorizontal: 8 },
confirmWarning: { lineHeight: 20 },
confirmActions: { flexDirection: 'row', gap: 10 },
blockedNotice: {

View file

@ -1,306 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react'
import {
ActivityIndicator,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useI18n } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { useEntitlement } from '../lib/entitlement-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import {
DashboardServiceError,
loadDashboardStats,
type MobileDashboardStats,
} from '../features/dashboard/dashboard-service'
function formatMinutes(seconds: number): string {
const minutes = seconds / 60
return minutes >= 10 ? String(Math.round(minutes)) : minutes.toFixed(1)
}
export default function DashScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const navigation = useNavigation<any>()
const { t } = useI18n()
const { user } = useAuth()
const { refresh: refreshEntitlement, snapshot: entitlementSnapshot } = useEntitlement()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
const [stats, setStats] = useState<MobileDashboardStats | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async (pullToRefresh = false): Promise<void> => {
if (!user?.id) {
setError('auth')
setLoading(false)
return
}
if (pullToRefresh) setRefreshing(true)
else setLoading(true)
try {
const [nextStats] = await Promise.all([
loadDashboardStats(user.id),
refreshEntitlement(),
])
setStats(nextStats)
setError(null)
} catch (candidate) {
setError(candidate instanceof DashboardServiceError ? candidate.code : 'server')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [refreshEntitlement, user?.id])
useFocusEffect(useCallback(() => {
void refresh(false)
}, [refresh]))
const llmUsage = Object.entries(entitlementSnapshot.usageToday)
.filter(([feature]) => feature.startsWith('llm_'))
.reduce((sum, [, count]) => sum + count, 0)
const sttUsage = entitlementSnapshot.usageToday.stt_transcribe ?? 0
const tier = entitlementSnapshot.tier.replace('_', ' ').toUpperCase()
return (
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 110 }]}
refreshControl={(
<RefreshControl
refreshing={refreshing}
onRefresh={() => void refresh(true)}
tintColor={palette.accent.main}
colors={[palette.accent.main]}
/>
)}
testID="dashboard-scroll"
>
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
<View>
<ThemeText variant="title" accessibilityRole="header">{t('mobile.dash.title')}</ThemeText>
<ThemeText variant="label" color="muted">{t('mobile.dash.sessionOverview')}</ThemeText>
</View>
<View style={styles.syncStatus}>
<View style={[
styles.statusDot,
{ backgroundColor: error ? palette.tag.red : palette.tag.green },
]} />
<ThemeText variant="label" color={error ? 'danger' : 'muted'}>
{error ? t('mobile.dash.syncError') : t('mobile.dash.synced')}
</ThemeText>
</View>
</View>
{loading && stats === null ? (
<View style={styles.loading} testID="dashboard-loading">
<ActivityIndicator color={palette.accent.main} />
<ThemeText color="muted">{t('mobile.dash.loading')}</ThemeText>
</View>
) : error && stats === null ? (
<ThemeCard style={styles.errorCard} testID="dashboard-error">
<ThemeText color="danger">{t('mobile.dash.loadError')}</ThemeText>
<ThemeButton
label={t('mobile.dash.retry')}
variant="secondary"
onPress={() => void refresh(false)}
/>
</ThemeCard>
) : stats ? (
<>
<ThemeCard inset style={styles.overviewPanel}>
<ThemeText variant="small" color="muted">{t('mobile.dash.today')}</ThemeText>
<View style={styles.heroRow}>
<ThemeText variant="hero" color="accent">{String(stats.todaySessions)}</ThemeText>
<ThemeText color="muted">{t('mobile.dash.sessions')}</ThemeText>
</View>
<ThemeButton
label={t('mobile.dash.tapToRecord')}
onPress={() => navigation.navigate('Record')}
testID="dashboard-start-recording"
/>
<View style={styles.overviewFooter}>
<Metric label={t('mobile.dash.words')} value={String(stats.todayWordCount)} />
<Metric label={t('mobile.dash.streak')} value={String(stats.streakDays)} alignRight />
</View>
</ThemeCard>
<View style={styles.statsGrid}>
<StatCard
label={t('mobile.dash.rec')}
value={formatMinutes(stats.totalRecordingSeconds)}
unit={t('mobile.dash.min')}
/>
<StatCard label={t('mobile.dash.words')} value={String(stats.totalWordCount)} />
<StatCard label={t('mobile.dash.today')} value={String(stats.todaySessions)} />
<StatCard label={t('mobile.dash.streak')} value={String(stats.streakDays)} />
</View>
<View style={styles.infoGrid}>
<ThemeCard style={styles.infoCard}>
<ThemeText variant="small" color="muted">{t('mobile.dash.backend')}</ThemeText>
<ThemeText color={error ? 'danger' : 'success'}>
{error ? t('mobile.dash.syncError') : t('mobile.dash.cloudSynced')}
</ThemeText>
</ThemeCard>
<ThemeCard style={styles.infoCard}>
<ThemeText variant="small" color="muted">{t('mobile.dash.tier')}</ThemeText>
<ThemeText color="accent">{tier}</ThemeText>
</ThemeCard>
</View>
<Pressable
onPress={() => navigation.navigate('ProPaywall')}
accessibilityRole="button"
testID="dashboard-open-paywall"
>
<ThemeCard style={styles.upgradeCard}>
<ThemeText variant="title" color="accent">{t('mobile.dash.rewardsTitle')}</ThemeText>
<ThemeText color="secondary" style={styles.upgradeText}>
{entitlementSnapshot.adFree
? t('mobile.dash.paidSummary')
: t('mobile.dash.freeSummary')}
</ThemeText>
<ThemeText variant="small" color="accent">{t('mobile.dash.openPlans')}</ThemeText>
</ThemeCard>
</Pressable>
<ThemeText variant="label" color="muted" style={styles.sectionTitle}>
{t('mobile.dash.usage')}
</ThemeText>
<ThemeCard style={styles.usageCard}>
<UsageRow label={t('mobile.dash.dictation')} value={t('mobile.dash.usedToday', { count: sttUsage })} />
<View style={styles.divider} />
<UsageRow label={t('mobile.dash.llmProcess')} value={t('mobile.dash.usedToday', { count: llmUsage })} />
<View style={styles.divider} />
<UsageRow
label={t('mobile.dash.rewardCredits')}
value={String(entitlementSnapshot.overageCredits)}
/>
</ThemeCard>
<View style={styles.recentHeader}>
<ThemeText variant="title">{t('mobile.dash.recent')}</ThemeText>
<ThemeText variant="label" color="muted">
{t('mobile.dash.items', { count: stats.recentHistory.length })}
</ThemeText>
</View>
{stats.recentHistory.length === 0 ? (
<ThemeCard><ThemeText color="muted">{t('mobile.dash.noHistory')}</ThemeText></ThemeCard>
) : stats.recentHistory.map((entry) => (
<Pressable
key={entry.id}
onPress={() => navigation.navigate('HistoryDetail', { historyId: entry.id })}
accessibilityRole="button"
>
<ThemeCard style={styles.recentCard}>
<ThemeText numberOfLines={1}>{entry.title || entry.text}</ThemeText>
<ThemeText variant="small" color="muted" numberOfLines={2}>{entry.text}</ThemeText>
<ThemeText variant="label" color="muted">
{new Date(entry.createdAt).toLocaleString()}
</ThemeText>
</ThemeCard>
</Pressable>
))}
</>
) : null}
</ScrollView>
)
}
function Metric({
label,
value,
alignRight = false,
}: {
label: string
value: string
alignRight?: boolean
}): React.ReactElement {
return (
<View style={alignRight ? { alignItems: 'flex-end' } : undefined}>
<ThemeText variant="label" color="muted">{label}</ThemeText>
<ThemeText variant="value" color="accent">{value}</ThemeText>
</View>
)
}
function StatCard({ label, value, unit }: { label: string; value: string; unit?: string }): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<ThemeCard style={[stylesStatic.statCard, { borderColor: palette.border.default }]}>
<ThemeText variant="label" color="muted">{label}</ThemeText>
<ThemeText variant="value" color="accent">{value}</ThemeText>
{unit && <ThemeText variant="label" color="muted">{unit}</ThemeText>}
</ThemeCard>
)
}
function UsageRow({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<View style={stylesStatic.usageRow}>
<ThemeText>{label}</ThemeText>
<ThemeText variant="small" color="accent">{value}</ThemeText>
</View>
)
}
type Palette = ReturnType<typeof useMobilePreferences>['palette']
const stylesStatic = StyleSheet.create({
// flex:1 + minWidth 47% → 2열 균등 분할 보장. width '47%' 고정은
// gap과 폭 계산이 맞지 않아 남는 여백이 우측으로 쏠리는 원인이었다.
statCard: { flex: 1, minWidth: '47%', alignItems: 'center', paddingVertical: 16, gap: 4 },
usageRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16 },
})
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
return StyleSheet.create({
container: { flex: 1, backgroundColor: palette.bg.app },
content: { paddingHorizontal: 20 },
header: {
minHeight: 92,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
syncStatus: { flexDirection: 'row', alignItems: 'center', gap: 7 },
statusDot: { width: 7, height: 7, borderRadius: 4 },
loading: { minHeight: 280, alignItems: 'center', justifyContent: 'center', gap: 12 },
errorCard: { gap: 14, marginTop: 40 },
overviewPanel: { marginBottom: 12, gap: 14 },
heroRow: { flexDirection: 'row', alignItems: 'baseline', gap: 10 },
overviewFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
borderTopColor: palette.border.subtle,
paddingTop: 12,
},
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, marginBottom: 12 },
infoGrid: { flexDirection: 'row', gap: 12, marginBottom: 12 },
infoCard: { flex: 1, gap: 8 },
upgradeCard: { borderColor: palette.accent.main, marginBottom: 16, gap: 8 },
upgradeText: { lineHeight: 20 },
sectionTitle: { letterSpacing: 0.4, marginBottom: 8, marginLeft: 4 },
usageCard: { marginBottom: 22, padding: 0, overflow: 'hidden' },
divider: { height: 1, backgroundColor: palette.border.subtle },
recentHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
},
recentCard: { marginBottom: 10, gap: 6 },
})
}

View file

@ -25,6 +25,7 @@ import type { DictionaryEntry } from '@d3ro/api-client'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import { ScreenHeader } from '../components/ScreenHeader'
import {
createDictionaryEntry,
deleteDictionaryEntry,
@ -345,20 +346,19 @@ export default function DictionaryScreen(): React.ReactElement {
return (
<View style={styles.container} testID="dictionary-screen">
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
<View>
<ThemeText variant="title" accessibilityRole="header">{t('mobile.dictionary.title')}</ThemeText>
<ThemeText variant="label" color="muted">
{t('mobile.dictionary.count', { count: total })}
</ThemeText>
</View>
<ThemeButton
label={t('mobile.dictionary.add')}
onPress={openAdd}
style={styles.addButton}
testID="dictionary-add"
/>
</View>
<ScreenHeader
title={t('mobile.dictionary.title')}
subtitle={t('mobile.dictionary.count', { count: total })}
rightContent={(
<ThemeButton
label={t('mobile.dictionary.add')}
onPress={openAdd}
style={styles.addButton}
testID="dictionary-add"
/>
)}
testID="dictionary-header"
/>
{!realtimeConnected && (
<View style={styles.realtimeBanner} testID="dictionary-realtime-warning">
@ -604,7 +604,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
borderBottomColor: palette.tag.red,
backgroundColor: palette.bg.card,
},
inlineRetry: { minWidth: 82, minHeight: 42 },
inlineRetry: { minWidth: 82, minHeight: 48 },
searchInput: {
minHeight: 48,
borderRadius: 10,
@ -617,7 +617,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
},
filters: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
filter: {
minHeight: 40,
minHeight: 48,
minWidth: 62,
paddingHorizontal: 12,
alignItems: 'center',
@ -660,7 +660,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
borderColor: palette.border.subtle,
},
entryActions: { flexDirection: 'row', gap: 10, marginTop: 4 },
entryButton: { flex: 1, minHeight: 44 },
entryButton: { flex: 1, minHeight: 48 },
loadMore: {
flexDirection: 'row',
justifyContent: 'center',
@ -699,7 +699,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
editorCategories: { flexDirection: 'row', gap: 8 },
editorCategory: {
flex: 1,
minHeight: 44,
minHeight: 48,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,

View file

@ -17,7 +17,7 @@ import {
PhosphorText,
PhysicalButton,
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import type { AudioFile, HistoryEntry } from '@d3ro/api-client'
import { useAuth } from '../lib/auth-context'
import {
@ -31,6 +31,44 @@ import {
import { loadHistoryCache } from '../features/history/history-cache'
import { useMobilePreferences } from '../lib/preferences-context'
const HISTORY_MODE_KEYS: Record<HistoryEntry['mode'], TranslationKey> = {
dictation: 'mobile.hist.mode.dictation',
translate: 'mobile.hist.mode.translate',
command: 'mobile.hist.mode.command',
caption: 'mobile.hist.mode.caption',
'file-transcription': 'mobile.hist.mode.fileTranscription',
}
const HISTORY_STATUS_KEYS: Record<HistoryEntry['status'], TranslationKey> = {
completed: 'mobile.hist.status.completed',
cancelled: 'mobile.hist.status.cancelled',
error: 'mobile.hist.status.error',
}
const AUDIO_STATUS_KEYS: Record<AudioFile['upload_status'], TranslationKey> = {
pending: 'mobile.hist.audioStatus.pending',
uploading: 'mobile.hist.audioStatus.uploading',
uploaded: 'mobile.hist.audioStatus.uploaded',
failed: 'mobile.hist.audioStatus.failed',
deleted: 'mobile.hist.audioStatus.deleted',
}
const JOB_KIND_KEYS: Record<HistoryDetail['jobs'][number]['kind'], TranslationKey> = {
transcription: 'mobile.hist.jobKind.transcription',
summary: 'mobile.hist.jobKind.summary',
minutes: 'mobile.hist.jobKind.minutes',
diarization: 'mobile.hist.jobKind.diarization',
export: 'mobile.hist.jobKind.export',
}
const JOB_STATUS_KEYS: Record<HistoryDetail['jobs'][number]['status'], TranslationKey> = {
queued: 'mobile.hist.jobStatus.queued',
running: 'mobile.hist.jobStatus.running',
succeeded: 'mobile.hist.jobStatus.succeeded',
failed: 'mobile.hist.jobStatus.failed',
cancelled: 'mobile.hist.jobStatus.cancelled',
}
interface HistoryDetailScreenProps {
route: { params: { historyId: string } }
navigation: { goBack: () => void }
@ -471,8 +509,8 @@ export default function HistoryDetailScreen({
)}
<Section title={t('mobile.hist.metadata')}>
<MetaRow label={t('mobile.hist.mode')} value={detail.entry.mode} />
<MetaRow label={t('mobile.hist.status')} value={detail.entry.status} />
<MetaRow label={t('mobile.hist.mode')} value={t(HISTORY_MODE_KEYS[detail.entry.mode])} />
<MetaRow label={t('mobile.hist.status')} value={t(HISTORY_STATUS_KEYS[detail.entry.status])} />
<MetaRow label={t('mobile.hist.language')} value={detail.entry.detected_language ?? '—'} />
<MetaRow label={t('mobile.hist.duration')} value={formatDuration(detail.entry.duration * 1000)} />
<MetaRow label={t('mobile.hist.model')} value={detail.entry.stt_model ?? '—'} />
@ -501,7 +539,7 @@ export default function HistoryDetailScreen({
</PhosphorText>
{audioFile.upload_status !== 'uploaded' && (
<PhosphorText variant="meta" color="amber">
{t('mobile.hist.audioPending', { status: audioFile.upload_status })}
{t('mobile.hist.audioPending', { status: t(AUDIO_STATUS_KEYS[audioFile.upload_status]) })}
</PhosphorText>
)}
</View>
@ -550,8 +588,8 @@ export default function HistoryDetailScreen({
) : detail.jobs.map((job) => (
<MetaRow
key={job.id}
label={job.kind}
value={`${job.status} · ${job.progress}%`}
label={t(JOB_KIND_KEYS[job.kind])}
value={`${t(JOB_STATUS_KEYS[job.status])} · ${job.progress}%`}
/>
))}
</Section>

View file

@ -10,20 +10,19 @@ import {
View,
} from 'react-native'
import type { NavigationProp, ParamListBase } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
MetalCard,
PhosphorText,
Led,
Header,
FilterChip,
AppStatusBar,
NativeThemeProvider,
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { useHistorySync } from '../features/history/use-history-sync'
import type { HistoryFilter, HistoryListEntry } from '../features/history/history-service'
import { useMobilePreferences } from '../lib/preferences-context'
import { ScreenHeader } from '../components/ScreenHeader'
import { useTabScreenBottomPadding } from '../navigation/tab-layout'
interface HistoryScreenProps {
navigation: NavigationProp<ParamListBase>
@ -34,7 +33,7 @@ type GroupedItem =
| { id: string; type: 'entry'; entry: HistoryListEntry }
export default function HistoryScreen({ navigation }: HistoryScreenProps): React.ReactElement {
const insets = useSafeAreaInsets()
const bottomPadding = useTabScreenBottomPadding()
const { t, formatTime, formatRelativeDate } = useI18n()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
@ -95,14 +94,15 @@ export default function HistoryScreen({ navigation }: HistoryScreenProps): React
return (
<NativeThemeProvider palette={palette}>
<View style={styles.container}>
<Header
<ScreenHeader
title={t('mobile.hist.title')}
showBack={false}
rightContent={
<PhosphorText variant="meta" color="muted">
{t('mobile.hist.entries', { count: history.entries.length })}
</PhosphorText>
}
paddingTop={insets.top}
testID="history-header"
/>
<View style={styles.searchRow}>
@ -177,8 +177,8 @@ export default function HistoryScreen({ navigation }: HistoryScreenProps): React
data={grouped}
keyExtractor={(item) => item.id}
contentContainerStyle={history.entries.length === 0
? styles.emptyList
: [styles.listContent, { paddingBottom: Math.max(insets.bottom, 24) + 40 }]}
? [styles.emptyList, { paddingBottom: bottomPadding }]
: [styles.listContent, { paddingBottom: bottomPadding }]}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl
@ -288,20 +288,12 @@ export default function HistoryScreen({ navigation }: HistoryScreenProps): React
</MetalCard>
)
}}
ListFooterComponent={
<>
{history.loadingMore && (
<View style={styles.loadingMore} testID="history-loading-more">
<ActivityIndicator size="small" color={palette.accent.main} />
<PhosphorText variant="meta" color="muted">{t('mobile.hist.loadMore')}</PhosphorText>
</View>
)}
<AppStatusBar
model={t('mobile.status.cloudModel')}
label={t('mobile.status.secureDataLink')}
/>
</>
}
ListFooterComponent={history.loadingMore ? (
<View style={styles.loadingMore} testID="history-loading-more">
<ActivityIndicator size="small" color={palette.accent.main} />
<PhosphorText variant="meta" color="muted">{t('mobile.hist.loadMore')}</PhosphorText>
</View>
) : null}
/>
</View>
</NativeThemeProvider>

View file

@ -0,0 +1,399 @@
// src/screens/HomeScreen.tsx — 홈(오늘). 데스크톱 HomePage 와 같은 문법:
// 제목·날짜 → 녹음 시작 안내 → 한 줄 요약 → 최근 기록 5 · 최근 회의 3 → (무료) 요금제 한 줄.
// 카드·통계 격자 없이 제목 + 1px 선. 조회한 값만 보여 주고, 못 불러오면 그렇다고 말한다.
import { useCallback, useMemo, useState } from 'react'
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {
type CompositeNavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native'
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { Meeting } from '@d3ro/api-client'
import { useI18n, type TFunction } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { useEntitlement } from '../lib/entitlement-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeText } from '../theme/themed-components'
import {
loadDashboardStats,
type MobileDashboardStats,
} from '../features/dashboard/dashboard-service'
import { listMeetingsPage } from '../features/meetings/meetings-service'
import type { RootStackParamList } from '../../App'
import type { MainTabParamList } from '../navigation/TabNavigator'
import { useTabScreenBottomPadding } from '../navigation/tab-layout'
const RECENT_RECORD_LIMIT = 5
const RECENT_MEETING_LIMIT = 3
type Palette = ReturnType<typeof useMobilePreferences>['palette']
type HomeNavigation = CompositeNavigationProp<
BottomTabNavigationProp<MainTabParamList, 'Home'>,
NativeStackNavigationProp<RootStackParamList>
>
function formatMinutes(seconds: number): string {
const minutes = seconds / 60
return minutes >= 10 ? String(Math.round(minutes)) : minutes.toFixed(1)
}
/** 오늘 n회 · 단어 · 녹음 시간 · 연속 일수 — 0 인 항목은 생략(같은 값을 두 번 말하지 않는다). */
export function buildTodaySummary(stats: MobileDashboardStats, t: TFunction): string {
const parts: string[] = []
if (stats.todaySessions === 0) {
parts.push(t('mobile.home.summaryNone'))
} else {
parts.push(t('mobile.home.summarySessions', { count: stats.todaySessions }))
if (stats.todayWordCount > 0) {
parts.push(t('mobile.home.summaryWords', { count: stats.todayWordCount }))
}
if (stats.todayRecordingSeconds > 0) {
parts.push(t('mobile.home.summaryMinutes', {
minutes: formatMinutes(stats.todayRecordingSeconds),
}))
}
}
if (stats.streakDays > 0) {
parts.push(t('mobile.home.summaryStreak', { count: stats.streakDays }))
}
return parts.join(' · ')
}
function meetingStatusLabel(status: Meeting['status'], t: TFunction): string {
if (status === 'recording') return t('mobile.meetings.statusRecording')
if (status === 'processing') return t('mobile.meetings.statusProcessing')
if (status === 'completed') return t('mobile.meetings.statusCompleted')
return t('mobile.meetings.statusError')
}
export default function HomeScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const bottomPadding = useTabScreenBottomPadding()
const navigation = useNavigation<HomeNavigation>()
const { t, formatDate, formatRelativeDate, formatTime } = useI18n()
const { user } = useAuth()
const { refresh: refreshEntitlement, snapshot: entitlementSnapshot } = useEntitlement()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
const [stats, setStats] = useState<MobileDashboardStats | null>(null)
const [statsError, setStatsError] = useState(false)
const [meetings, setMeetings] = useState<Meeting[] | null>(null)
const [meetingsError, setMeetingsError] = useState(false)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(async (pullToRefresh = false): Promise<void> => {
if (!user?.id) {
setStatsError(true)
setMeetingsError(true)
setLoading(false)
return
}
if (pullToRefresh) setRefreshing(true)
else setLoading(true)
const [statsResult, meetingsResult] = await Promise.allSettled([
loadDashboardStats(user.id),
listMeetingsPage({ userId: user.id, page: 0, pageSize: RECENT_MEETING_LIMIT }),
refreshEntitlement(),
])
if (statsResult.status === 'fulfilled') {
setStats(statsResult.value)
setStatsError(false)
} else {
setStatsError(true)
}
if (meetingsResult.status === 'fulfilled') {
setMeetings(meetingsResult.value.meetings)
setMeetingsError(false)
} else {
setMeetingsError(true)
}
setLoading(false)
setRefreshing(false)
}, [refreshEntitlement, user?.id])
useFocusEffect(useCallback(() => {
void refresh(false)
}, [refresh]))
const today = formatDate(new Date(), { month: 'long', day: 'numeric', weekday: 'long' })
const recentRecords = stats?.recentHistory.slice(0, RECENT_RECORD_LIMIT) ?? []
const recentMeetings = meetings?.slice(0, RECENT_MEETING_LIMIT) ?? []
return (
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.content,
{ paddingTop: insets.top + 16, paddingBottom: bottomPadding },
]}
refreshControl={(
<RefreshControl
refreshing={refreshing}
onRefresh={() => void refresh(true)}
tintColor={palette.accent.main}
colors={[palette.accent.main]}
/>
)}
testID="home-screen"
>
<View style={styles.header}>
<ThemeText variant="title" accessibilityRole="header">{t('mobile.home.title')}</ThemeText>
<ThemeText variant="small" color="muted">{today}</ThemeText>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('unifiedSearch.title')}
accessibilityHint={t('unifiedSearch.homeHint')}
onPress={() => navigation.navigate('UnifiedSearch')}
testID="home-open-unified-search"
style={({ pressed }) => [
styles.searchEntry,
{ borderColor: palette.border.subtle },
pressed && { backgroundColor: palette.bg.cardHover },
]}
>
<View style={styles.searchGlyph} accessibilityElementsHidden>
<View style={[styles.searchRing, { borderColor: palette.text.muted }]} />
<View style={[styles.searchHandle, { backgroundColor: palette.text.muted }]} />
</View>
<View style={styles.flex}>
<ThemeText>{t('unifiedSearch.title')}</ThemeText>
<ThemeText variant="small" color="muted" numberOfLines={1}>
{t('unifiedSearch.homeHint')}
</ThemeText>
</View>
<View style={[styles.chevron, { borderColor: palette.text.muted }]} />
</Pressable>
<ThemeText color="secondary" style={styles.hint}>{t('mobile.home.recordHint')}</ThemeText>
<View style={styles.summary} testID="home-summary">
{stats !== null ? (
<ThemeText color="primary">{buildTodaySummary(stats, t)}</ThemeText>
) : loading ? (
<ThemeText color="muted" testID="home-loading">{t('mobile.home.loading')}</ThemeText>
) : statsError ? (
<View style={styles.errorRow} testID="home-error">
<ThemeText color="danger" style={styles.flex}>{t('mobile.home.loadError')}</ThemeText>
<ThemeButton
label={t('mobile.home.retry')}
variant="quiet"
onPress={() => void refresh(false)}
testID="home-retry"
/>
</View>
) : null}
</View>
<SectionHeader
title={t('mobile.home.recentRecords')}
actionLabel={t('mobile.home.seeAll')}
actionAccessibilityLabel={t('mobile.home.seeAllRecords')}
onAction={() => navigation.navigate('Records')}
testID="home-see-all-records"
/>
{stats !== null && recentRecords.length === 0 ? (
<ThemeText color="muted" style={styles.empty}>{t('mobile.home.noRecords')}</ThemeText>
) : recentRecords.map((entry) => {
const createdAt = Date.parse(entry.createdAt)
const title = entry.title?.trim() || entry.text.trim()
return (
<ListRow
key={entry.id}
title={title}
meta={`${formatRelativeDate(createdAt)} ${formatTime(createdAt)}`}
onPress={() => navigation.navigate('HistoryDetail', { historyId: entry.id })}
testID={`home-record-${entry.id}`}
/>
)
})}
<SectionHeader
title={t('mobile.home.recentMeetings')}
actionLabel={t('mobile.home.seeAll')}
actionAccessibilityLabel={t('mobile.home.seeAllMeetings')}
onAction={() => navigation.navigate('Meetings')}
testID="home-see-all-meetings"
/>
{meetingsError && meetings === null ? (
<ThemeText color="danger" style={styles.empty}>{t('mobile.home.meetingsLoadError')}</ThemeText>
) : meetings !== null && recentMeetings.length === 0 ? (
<ThemeText color="muted" style={styles.empty}>{t('mobile.home.noMeetings')}</ThemeText>
) : recentMeetings.map((meeting) => {
const startedAt = Date.parse(meeting.started_at)
return (
<ListRow
key={meeting.id}
title={meeting.title?.trim() || t('mobile.meetings.untitled')}
meta={`${formatRelativeDate(startedAt)} ${formatTime(startedAt)} · ${meetingStatusLabel(meeting.status, t)}`}
onPress={() => navigation.navigate('MeetingDetail', { meetingId: meeting.id })}
testID={`home-meeting-${meeting.id}`}
/>
)
})}
{entitlementSnapshot.tier === 'free' && (
<Pressable
accessibilityRole="button"
accessibilityHint={t('mobile.home.freePlanHint')}
onPress={() => navigation.navigate('ProPaywall')}
testID="home-open-plans"
style={({ pressed }) => [
styles.planLine,
{ borderTopColor: palette.border.subtle },
pressed && { backgroundColor: palette.bg.cardHover },
]}
>
<ThemeText variant="small" color="muted">{t('mobile.home.freePlan')}</ThemeText>
</Pressable>
)}
</ScrollView>
)
}
function SectionHeader({
title,
actionLabel,
actionAccessibilityLabel,
onAction,
testID,
}: {
title: string
actionLabel: string
actionAccessibilityLabel: string
onAction: () => void
testID: string
}): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<View style={[stylesStatic.sectionHeader, { borderBottomColor: palette.border.subtle }]}>
<ThemeText variant="heading" accessibilityRole="header">{title}</ThemeText>
<Pressable
accessibilityRole="button"
accessibilityLabel={actionAccessibilityLabel}
onPress={onAction}
hitSlop={4}
testID={testID}
style={({ pressed }) => [stylesStatic.sectionAction, pressed && { opacity: 0.6 }]}
>
<ThemeText variant="small" color="accent">{actionLabel}</ThemeText>
</Pressable>
</View>
)
}
function ListRow({
title,
meta,
onPress,
testID,
}: {
title: string
meta: string
onPress: () => void
testID: string
}): React.ReactElement {
const { palette } = useMobilePreferences()
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${title}, ${meta}`}
onPress={onPress}
testID={testID}
style={({ pressed }) => [
stylesStatic.row,
{ borderBottomColor: palette.border.subtle },
pressed && { backgroundColor: palette.bg.cardHover },
]}
>
<ThemeText numberOfLines={1}>{title}</ThemeText>
<ThemeText variant="small" color="muted" numberOfLines={1}>{meta}</ThemeText>
</Pressable>
)
}
const stylesStatic = StyleSheet.create({
sectionHeader: {
marginTop: 28,
minHeight: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
},
sectionAction: { minHeight: 48, minWidth: 48, alignItems: 'flex-end', justifyContent: 'center' },
row: {
minHeight: 56,
paddingVertical: 10,
justifyContent: 'center',
gap: 2,
borderBottomWidth: 1,
},
})
function createStyles(palette: Palette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: palette.bg.app },
content: { paddingHorizontal: 20 },
header: { gap: 2 },
searchEntry: {
minHeight: 64,
marginTop: 16,
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderTopWidth: 1,
borderBottomWidth: 1,
},
searchGlyph: { width: 24, height: 24 },
searchRing: {
position: 'absolute',
left: 2,
top: 2,
width: 14,
height: 14,
borderWidth: 1.5,
borderRadius: 7,
},
searchHandle: {
position: 'absolute',
left: 15,
top: 15,
width: 8,
height: 1.5,
borderRadius: 1,
transform: [{ rotate: '45deg' }],
},
chevron: {
width: 8,
height: 8,
marginRight: 4,
borderTopWidth: 1.5,
borderRightWidth: 1.5,
transform: [{ rotate: '45deg' }],
},
hint: { marginTop: 16 },
summary: { marginTop: 12, minHeight: 22 },
errorRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
flex: { flex: 1 },
empty: { paddingVertical: 14 },
planLine: {
marginTop: 28,
minHeight: 48,
justifyContent: 'center',
borderTopWidth: 1,
},
})
}

View file

@ -15,6 +15,7 @@ import { useI18n, type TFunction } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import { ScreenHeader } from '../components/ScreenHeader'
import { pickKnowledgeTextFile } from '../features/knowledge/knowledge-file-picker'
import {
createKnowledgeDocument,
@ -308,22 +309,19 @@ export default function KnowledgeScreen(): React.ReactElement {
const header = (
<View>
<View style={[styles.header, { paddingTop: insets.top + 12 }] }>
<View style={styles.headerCopy}>
<ThemeText variant="title" accessibilityRole="header">
{t('mobile.knowledge.title')}
</ThemeText>
<ThemeText variant="label" color="muted">
{t('mobile.knowledge.count', { count: total })}
</ThemeText>
</View>
<ThemeButton
label={importing ? t('mobile.knowledge.importing') : t('mobile.knowledge.import')}
disabled={importing || indexingId !== null || user === null}
onPress={() => { void importDocument() }}
testID="knowledge-import"
/>
</View>
<ScreenHeader
title={t('mobile.knowledge.title')}
subtitle={t('mobile.knowledge.count', { count: total })}
rightContent={(
<ThemeButton
label={importing ? t('mobile.knowledge.importing') : t('mobile.knowledge.import')}
disabled={importing || indexingId !== null || user === null}
onPress={() => { void importDocument() }}
testID="knowledge-import"
/>
)}
testID="knowledge-header"
/>
{!connected && user !== null && (
<View style={styles.syncBanner} testID="knowledge-realtime-pending">

View file

@ -14,8 +14,14 @@ import {
} from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { Meeting, MeetingDocument, MeetingMemo, UserTemplate } from '@d3ro/api-client'
import { useI18n } from '@d3ro/i18n'
import type {
DocumentTemplateType,
Meeting,
MeetingDocument,
MeetingMemo,
UserTemplate,
} from '@d3ro/api-client'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
@ -48,6 +54,14 @@ import {
type MeetingDetail,
} from '../features/meetings/meetings-service'
const TEMPLATE_TYPE_KEYS: Record<DocumentTemplateType, TranslationKey> = {
minutes: 'mobile.templates.type.minutes',
report: 'mobile.templates.type.report',
'idea-note': 'mobile.templates.type.ideaNote',
custom: 'mobile.templates.type.custom',
mindmap: 'mobile.templates.type.mindmap',
}
interface MeetingDetailScreenProps {
route: { params: { meetingId: string } }
navigation: {
@ -787,7 +801,7 @@ export default function MeetingDetailScreen({
<View style={styles.documentTitleWrap}>
<ThemeText>{document.title}</ThemeText>
<ThemeText variant="label" color="muted">
{document.template_type} · {formatDate(new Date(document.updated_at), { dateStyle: 'medium' })}
{t(TEMPLATE_TYPE_KEYS[document.template_type])} · {formatDate(new Date(document.updated_at), { dateStyle: 'medium' })}
</ThemeText>
</View>
<ThemeText color="accent">{expanded ? '−' : '+'}</ThemeText>

View file

@ -15,12 +15,12 @@ import {
} from 'react-native'
import type { NavigationProp, ParamListBase } from '@react-navigation/native'
import { useFocusEffect } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { Meeting, MeetingLanguage, UserTemplate } from '@d3ro/api-client'
import { useI18n } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import { ScreenHeader } from '../components/ScreenHeader'
import {
createMeetingCreationIdempotencyKey,
createMeetingWorkspaceWithMetadata,
@ -30,9 +30,11 @@ import {
updateMeetingTitleRevisionSafe,
} from '../features/meetings/meetings-service'
import { loadTemplateLibrary } from '../features/templates'
import { useTabScreenBottomPadding } from '../navigation/tab-layout'
interface MeetingsScreenProps {
navigation: NavigationProp<ParamListBase>
route?: { params?: { createRequest?: number } }
}
type EditorMode =
@ -44,8 +46,9 @@ const MEETING_LANGUAGES = ['ko', 'en', 'ja', 'zh-cn'] as const
export default function MeetingsScreen({
navigation,
route,
}: MeetingsScreenProps): React.ReactElement {
const insets = useSafeAreaInsets()
const bottomPadding = useTabScreenBottomPadding()
const { t, formatDate } = useI18n()
const { user } = useAuth()
const { palette } = useMobilePreferences()
@ -54,6 +57,8 @@ export default function MeetingsScreen({
const templateRequestGeneration = useRef(0)
const editorBusyRef = useRef(false)
const editorUserIdRef = useRef(user?.id ?? null)
const handledCreateRequests = useRef(new Set<number>())
const openCreateRef = useRef<() => void>(() => undefined)
const [meetings, setMeetings] = useState<Meeting[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
@ -217,6 +222,16 @@ export default function MeetingsScreen({
void loadCreationTemplates()
}
openCreateRef.current = openCreate
useEffect(() => {
const request = route?.params?.createRequest
if (request === undefined || handledCreateRequests.current.has(request)) return
handledCreateRequests.current.add(request)
navigation.setParams({ createRequest: undefined })
openCreateRef.current()
}, [navigation, route?.params?.createRequest])
const openEdit = (meeting: Meeting): void => {
setEditor({ kind: 'edit', meeting })
setEditorTitle(meeting.title ?? '')
@ -310,21 +325,19 @@ export default function MeetingsScreen({
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
<View style={styles.headerCopy}>
<ThemeText variant="title" accessibilityRole="header">
{t('mobile.meetings.title')}
</ThemeText>
<ThemeText variant="label" color="muted">
{t('mobile.meetings.count', { count: total })}
</ThemeText>
</View>
<ThemeButton
label={t('mobile.meetings.create')}
onPress={openCreate}
testID="meetings-create"
/>
</View>
<ScreenHeader
title={t('mobile.meetings.title')}
subtitle={t('mobile.meetings.count', { count: total })}
showBack={false}
rightContent={(
<ThemeButton
label={t('mobile.meetings.create')}
onPress={openCreate}
testID="meetings-create"
/>
)}
testID="meetings-header"
/>
<View style={styles.searchWrap}>
<TextInput
@ -363,7 +376,10 @@ export default function MeetingsScreen({
<FlatList
data={meetings}
keyExtractor={(meeting) => meeting.id}
contentContainerStyle={meetings.length === 0 ? styles.emptyList : styles.list}
contentContainerStyle={[
meetings.length === 0 ? styles.emptyList : styles.list,
{ paddingBottom: bottomPadding },
]}
keyboardShouldPersistTaps="handled"
refreshControl={(
<RefreshControl
@ -388,7 +404,7 @@ export default function MeetingsScreen({
<View style={styles.loadingMore} testID="meetings-loading-more">
<ActivityIndicator color={palette.accent.main} />
</View>
) : <View style={{ height: insets.bottom + 100 }} />}
) : null}
renderItem={({ item: meeting }) => {
const busy = busyId === meeting.id
const owned = meeting.user_id === user?.id
@ -791,7 +807,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
},
attendeesInput: { minHeight: 86, paddingVertical: 10 },
choiceRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
choice: { minHeight: 42, minWidth: 64, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, borderRadius: 9, borderWidth: 1, borderColor: palette.border.default },
choice: { minHeight: 48, minWidth: 64, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, borderRadius: 9, borderWidth: 1, borderColor: palette.border.default },
choiceSelected: { backgroundColor: palette.accent.main, borderColor: palette.accent.main },
templateLoading: { minHeight: 52, flexDirection: 'row', alignItems: 'center', gap: 10 },
templateEmpty: { minHeight: 52, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10 },

View file

@ -24,6 +24,7 @@ import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import { ScreenHeader } from '../components/ScreenHeader'
import {
addMemoTag,
buildMemoShareText,
@ -393,10 +394,11 @@ export default function MemosScreen(): React.ReactElement {
return (
<View style={styles.container} testID="memos-screen">
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
<ThemeText variant="title" accessibilityRole="header">{t('mobile.memos.title')}</ThemeText>
<ThemeText variant="small" color="muted">{t('mobile.memos.description')}</ThemeText>
</View>
<ScreenHeader
title={t('mobile.memos.title')}
subtitle={t('mobile.memos.description')}
testID="memos-header"
/>
{!realtimeConnected && (
<View style={styles.banner} testID="memos-realtime-warning">
<ThemeText variant="small" color="muted">{t('mobile.memos.realtimeWaiting')}</ThemeText>
@ -549,9 +551,9 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
input: { minHeight: 48, marginHorizontal: 16, borderWidth: 1, borderColor: palette.border.default, borderRadius: 10, backgroundColor: palette.bg.inset, color: palette.text.primary, paddingHorizontal: 12, fontSize: 14 },
tagFilters: { paddingHorizontal: 16, gap: 8 },
filterGroup: { flexDirection: 'row', alignItems: 'center', borderRadius: 999, borderWidth: 1, borderColor: palette.border.default, overflow: 'hidden' },
filter: { minHeight: 40, justifyContent: 'center', paddingHorizontal: 13, borderRadius: 999, borderWidth: 1, borderColor: palette.border.default },
filter: { minHeight: 48, justifyContent: 'center', paddingHorizontal: 13, borderRadius: 999, borderWidth: 1, borderColor: palette.border.default },
filterActive: { backgroundColor: palette.accent.main, borderColor: palette.accent.main },
renameButton: { minWidth: 40, minHeight: 40, alignItems: 'center', justifyContent: 'center', borderLeftWidth: 1, borderLeftColor: palette.border.subtle },
renameButton: { minWidth: 48, minHeight: 48, alignItems: 'center', justifyContent: 'center', borderLeftWidth: 1, borderLeftColor: palette.border.subtle },
inlineError: { padding: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10, borderBottomWidth: 1, borderBottomColor: palette.tag.red },
list: { padding: 16, gap: 12 },
emptyList: { flexGrow: 1 },
@ -562,7 +564,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
cardTitle: { flex: 1, gap: 4 },
body: { lineHeight: 22 },
tags: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 7 },
tag: { minHeight: 38, justifyContent: 'center', paddingHorizontal: 10, borderRadius: 999, backgroundColor: palette.accent.dim },
tag: { minHeight: 48, 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: palette.scrim },

View file

@ -0,0 +1,362 @@
// src/screens/MoreScreen.tsx — 1급 화면 밖의 모든 기능을 보존하는 도구·계정 목록.
import { useMemo } from 'react'
import {
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {
type CompositeNavigationProp,
useNavigation,
} from '@react-navigation/native'
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { currentAdminRole } from '../features/admin/admin-service'
import { useAuth } from '../lib/auth-context'
import { useEntitlement, type MobileSubscriptionTier } from '../lib/entitlement-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { useTabScreenBottomPadding } from '../navigation/tab-layout'
import { ThemeText } from '../theme/themed-components'
import type { RootStackParamList } from '../../App'
import type { MainTabParamList } from '../navigation/TabNavigator'
type MoreNavigation = CompositeNavigationProp<
BottomTabNavigationProp<MainTabParamList, 'More'>,
NativeStackNavigationProp<RootStackParamList>
>
type MoreRoute = {
[RouteName in keyof RootStackParamList]: undefined extends RootStackParamList[RouteName]
? RouteName
: never
}[keyof RootStackParamList]
type GlyphType = 'conversation' | 'document' | 'people' | 'automation' | 'account'
interface MoreItem {
route: MoreRoute
titleKey: TranslationKey
descriptionKey: TranslationKey
testID: string
glyph: GlyphType
}
const TOOL_ITEMS: readonly MoreItem[] = [
{
route: 'Talk',
titleKey: 'mobile.more.item.talk',
descriptionKey: 'mobile.more.item.talkDescription',
testID: 'open-talk',
glyph: 'conversation',
},
{
route: 'Commands',
titleKey: 'mobile.more.item.commands',
descriptionKey: 'mobile.more.item.commandsDescription',
testID: 'open-commands',
glyph: 'automation',
},
{
route: 'Dictionary',
titleKey: 'mobile.more.item.dictionary',
descriptionKey: 'mobile.more.item.dictionaryDescription',
testID: 'open-dictionary',
glyph: 'document',
},
{
route: 'Knowledge',
titleKey: 'mobile.more.item.knowledge',
descriptionKey: 'mobile.more.item.knowledgeDescription',
testID: 'open-knowledge',
glyph: 'document',
},
{
route: 'Templates',
titleKey: 'mobile.more.item.templates',
descriptionKey: 'mobile.more.item.templatesDescription',
testID: 'open-templates',
glyph: 'document',
},
{
route: 'Memos',
titleKey: 'mobile.more.item.tags',
descriptionKey: 'mobile.more.item.tagsDescription',
testID: 'open-memos',
glyph: 'document',
},
{
route: 'Teams',
titleKey: 'mobile.more.item.teams',
descriptionKey: 'mobile.more.item.teamsDescription',
testID: 'open-teams',
glyph: 'people',
},
{
route: 'Actions',
titleKey: 'mobile.more.item.actions',
descriptionKey: 'mobile.more.item.actionsDescription',
testID: 'open-actions',
glyph: 'automation',
},
{
route: 'Notifications',
titleKey: 'mobile.more.item.notifications',
descriptionKey: 'mobile.more.item.notificationsDescription',
testID: 'open-notifications',
glyph: 'conversation',
},
{
route: 'DataPortability',
titleKey: 'mobile.more.item.portability',
descriptionKey: 'mobile.more.item.portabilityDescription',
testID: 'open-data-portability',
glyph: 'automation',
},
]
const ACCOUNT_ITEMS: readonly MoreItem[] = [
{
route: 'Account',
titleKey: 'mobile.more.item.account',
descriptionKey: 'mobile.more.item.accountDescription',
testID: 'open-account',
glyph: 'account',
},
{
route: 'ProPaywall',
titleKey: 'mobile.more.item.plan',
descriptionKey: 'mobile.more.item.planDescription',
testID: 'open-paywall',
glyph: 'account',
},
{
route: 'Devices',
titleKey: 'mobile.more.item.devices',
descriptionKey: 'mobile.more.item.devicesDescription',
testID: 'open-devices',
glyph: 'account',
},
{
route: 'Settings',
titleKey: 'mobile.more.item.settings',
descriptionKey: 'mobile.more.item.settingsDescription',
testID: 'more-open-settings',
glyph: 'account',
},
]
const ADMIN_ITEM: MoreItem = {
route: 'Admin',
titleKey: 'mobile.more.item.admin',
descriptionKey: 'mobile.more.item.adminDescription',
testID: 'open-admin',
glyph: 'account',
}
const TIER_KEYS: Record<MobileSubscriptionTier, TranslationKey> = {
free: 'mobile.more.tier.free',
pro: 'mobile.more.tier.pro',
pro_plus: 'mobile.more.tier.pro_plus',
}
export default function MoreScreen(): React.ReactElement {
const navigation = useNavigation<MoreNavigation>()
const insets = useSafeAreaInsets()
const bottomPadding = useTabScreenBottomPadding({ avoidFab: false })
const { t } = useI18n()
const { user } = useAuth()
const { snapshot } = useEntitlement()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
const accountItems = currentAdminRole(user?.app_metadata) === null
? ACCOUNT_ITEMS
: [...ACCOUNT_ITEMS, ADMIN_ITEM]
const description = (item: MoreItem): string => (
item.route === 'ProPaywall'
? t(item.descriptionKey, { tier: t(TIER_KEYS[snapshot.tier]) })
: t(item.descriptionKey)
)
return (
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.content,
{ paddingTop: insets.top + 16, paddingBottom: bottomPadding },
]}
testID="more-screen"
>
<ThemeText variant="title" accessibilityRole="header" style={styles.title}>
{t('mobile.more.title')}
</ThemeText>
<MoreSection
title={t('mobile.more.section.tools')}
items={TOOL_ITEMS}
describe={description}
onOpen={(route) => navigation.navigate(route)}
/>
<MoreSection
title={t('mobile.more.section.account')}
items={accountItems}
describe={description}
onOpen={(route) => navigation.navigate(route)}
/>
</ScrollView>
)
}
function MoreSection({
title,
items,
describe,
onOpen,
}: {
title: string
items: readonly MoreItem[]
describe: (item: MoreItem) => string
onOpen: (route: MoreRoute) => void
}): React.ReactElement {
const { t } = useI18n()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
return (
<View style={styles.section}>
<ThemeText variant="label" color="muted" style={styles.sectionTitle}>
{title}
</ThemeText>
<View style={[styles.rows, { borderColor: palette.border.subtle }]}>
{items.map((item, index) => {
const itemDescription = describe(item)
return (
<Pressable
key={item.route}
accessibilityRole="button"
accessibilityLabel={t(item.titleKey)}
accessibilityHint={itemDescription}
onPress={() => onOpen(item.route)}
testID={item.testID}
style={({ pressed }) => [
styles.row,
index > 0 && { borderTopColor: palette.border.subtle, borderTopWidth: 1 },
pressed && { backgroundColor: palette.bg.cardHover },
]}
>
<RowGlyph type={item.glyph} color={palette.accent.main} />
<View style={styles.copy}>
<ThemeText>{t(item.titleKey)}</ThemeText>
<ThemeText variant="small" color="muted" numberOfLines={2}>
{itemDescription}
</ThemeText>
</View>
<View style={[glyph.chevron, { borderColor: palette.text.muted }]} />
</Pressable>
)
})}
</View>
</View>
)
}
function RowGlyph({ type, color }: { type: GlyphType; color: string }): React.ReactElement {
return (
<View style={[glyph.frame, { borderColor: color }]}>
{type === 'conversation' && (
<View style={glyph.stack}>
<View style={[glyph.line, glyph.longLine, { backgroundColor: color }]} />
<View style={[glyph.line, glyph.shortLine, { backgroundColor: color }]} />
</View>
)}
{type === 'document' && (
<View style={glyph.stack}>
<View style={[glyph.line, glyph.longLine, { backgroundColor: color }]} />
<View style={[glyph.line, glyph.longLine, { backgroundColor: color }]} />
<View style={[glyph.line, glyph.shortLine, { backgroundColor: color }]} />
</View>
)}
{type === 'people' && (
<>
<View style={[glyph.dot, { borderColor: color }]} />
<View style={[glyph.dot, { borderColor: color }]} />
</>
)}
{type === 'automation' && (
<>
<View style={[glyph.node, { backgroundColor: color }]} />
<View style={[glyph.connector, { backgroundColor: color }]} />
<View style={[glyph.node, { backgroundColor: color }]} />
</>
)}
{type === 'account' && (
<>
<View style={[glyph.accountHead, { borderColor: color }]} />
<View style={[glyph.accountBody, { borderColor: color }]} />
</>
)}
</View>
)
}
type Palette = ReturnType<typeof useMobilePreferences>['palette']
function createStyles(palette: Palette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: palette.bg.app },
content: { paddingHorizontal: 20 },
title: { marginBottom: 8 },
section: { marginTop: 20 },
sectionTitle: { minHeight: 32, justifyContent: 'center', letterSpacing: 0.4 },
rows: { borderTopWidth: 1, borderBottomWidth: 1 },
row: {
minHeight: 72,
flexDirection: 'row',
alignItems: 'center',
gap: 14,
paddingVertical: 10,
},
copy: { flex: 1, gap: 3 },
})
}
const glyph = StyleSheet.create({
frame: {
width: 28,
height: 28,
borderWidth: 1.5,
borderRadius: 7,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
flexDirection: 'row',
},
line: { height: 1.5, borderRadius: 1 },
stack: { gap: 2 },
longLine: { width: 12 },
shortLine: { width: 8 },
dot: { width: 7, height: 7, borderWidth: 1.5, borderRadius: 4 },
node: { width: 4, height: 4, borderRadius: 2 },
connector: { width: 8, height: 1.5 },
accountHead: { position: 'absolute', top: 5, width: 7, height: 7, borderWidth: 1.5, borderRadius: 4 },
accountBody: {
position: 'absolute',
bottom: 4,
width: 14,
height: 8,
borderWidth: 1.5,
borderBottomWidth: 0,
borderTopLeftRadius: 7,
borderTopRightRadius: 7,
},
chevron: {
width: 8,
height: 8,
marginRight: 4,
borderTopWidth: 1.5,
borderRightWidth: 1.5,
transform: [{ rotate: '45deg' }],
},
})

View file

@ -7,19 +7,18 @@ import {
StyleSheet,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
AppStatusBar,
Header,
Led,
NativeThemeProvider,
PhosphorText,
PhysicalButton,
WaveBars,
d3roNativeFonts,
d3roNativeNumerals,
} from '@d3ro/ui-native';
import { useI18n } from '@d3ro/i18n';
import { useAuth } from '../lib/auth-context';
import { ScreenHeader } from '../components/ScreenHeader';
import {
audioRecorder,
RecorderBusyError,
@ -122,17 +121,21 @@ interface RecordScreenMeetingParams {
meetingLanguage?: string;
}
interface RecordScreenParams extends RecordScreenMeetingParams {
incomingMediaId?: string;
importRequest?: number;
}
interface RecordScreenProps {
route: { params?: RecordScreenMeetingParams & { incomingMediaId?: string } }
route: { params?: RecordScreenParams }
navigation: {
getParent: () => { navigate: (name: 'MeetingDetail', params: { meetingId: string }) => void } | undefined
/** Clears the meeting binding once its capture is committed. */
setParams?: (params: RecordScreenMeetingParams) => void
/** Clears one-shot route bindings once their action is committed. */
setParams?: (params: Partial<RecordScreenParams>) => void
}
}
export default function RecordScreen({ route, navigation }: RecordScreenProps): React.ReactElement {
const insets = useSafeAreaInsets();
const { t, locale } = useI18n();
const { user } = useAuth();
const { palette } = useMobilePreferences();
@ -166,7 +169,10 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
const routeMeetingId = route.params?.meetingId ?? null;
const routeMeetingTitle = route.params?.meetingTitle ?? null;
const routeIncomingMediaId = route.params?.incomingMediaId ?? null;
const routeImportRequest = route.params?.importRequest;
const handledIncomingMediaIds = useRef(new Set<string>());
const handledImportRequests = useRef(new Set<number>());
const importAudioFileRef = useRef<() => Promise<void>>(async () => undefined);
// Latest route binding, read by callbacks registered on an earlier render.
const routeMeetingIdRef = useRef<string | null>(routeMeetingId);
routeMeetingIdRef.current = routeMeetingId;
@ -643,6 +649,20 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
}
}
importAudioFileRef.current = importAudioFile;
useEffect(() => {
if (
routeImportRequest === undefined ||
handledImportRequests.current.has(routeImportRequest) ||
actionPending ||
!['idle', 'done', 'error'].includes(state)
) return;
handledImportRequests.current.add(routeImportRequest);
navigation.setParams?.({ importRequest: undefined });
void importAudioFileRef.current();
}, [actionPending, navigation, routeImportRequest, state]);
async function importIncomingMedia(): Promise<void> {
if (!beginAction()) return;
const previousState = state;
@ -792,15 +812,16 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
return (
<NativeThemeProvider palette={palette}>
<ScrollView style={styles.container} contentContainerStyle={styles.content} testID="record-screen">
<Header
<ScreenHeader
title={captureActive ? t('mobile.rec.session') : t('mobile.rec.title')}
paddingTop={insets.top}
rightContent={captureActive ? (
<View style={styles.headerRight}>
<PhosphorText variant="label" color="amber">{formatTime(durationMs)}</PhosphorText>
<Led color={state === 'recording' ? 'green' : 'amber'} size={6} />
<PhosphorText variant="label" color="amber" style={styles.headerTime}>
{formatTime(durationMs)}
</PhosphorText>
</View>
) : undefined}
testID="record-header"
/>
<WaveBars active={state === 'recording'} />
@ -983,10 +1004,6 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
/>
)}
</View>
<AppStatusBar
model={t('mobile.status.cloudModel')}
label={t('mobile.status.secureDataLink')}
/>
</ScrollView>
</NativeThemeProvider>
);
@ -999,12 +1016,13 @@ function createStyles(palette: Palette) {
container: { flex: 1, backgroundColor: palette.bg.app },
content: { paddingBottom: 100 },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
headerTime: { ...d3roNativeNumerals },
meetingContext: { paddingHorizontal: 20, paddingTop: 12, gap: 4 },
statusRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingHorizontal: 20, paddingTop: 12 },
statusText: { letterSpacing: 2 },
transcriptArea: { minHeight: 190, justifyContent: 'center', alignItems: 'center', gap: 14, paddingHorizontal: 20, paddingVertical: 24 },
initLog: { fontFamily: d3roNativeFonts.mono, textAlign: 'center' },
fileName: { fontFamily: d3roNativeFonts.mono, textAlign: 'center' },
initLog: { fontFamily: d3roNativeFonts.sans, textAlign: 'center' },
fileName: { fontFamily: d3roNativeFonts.sans, ...d3roNativeNumerals, textAlign: 'center' },
transcriptBox: { backgroundColor: palette.bg.inset, padding: 14, borderRadius: 8, width: '100%', gap: 14 },
resultMeta: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 12 },
errorBox: { borderWidth: 1, borderColor: palette.tag.red, backgroundColor: palette.bg.card, padding: 10, borderRadius: 6, width: '100%' },

View file

@ -8,7 +8,6 @@ import {
Switch,
View,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useNavigation } from '@react-navigation/native'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { SITE_URLS } from '@d3ro/core/web-urls'
@ -33,6 +32,7 @@ import {
import { useMobileAds } from '../lib/mobile-ads-context'
import { useDevice } from '../lib/device-context'
import { currentAdminRole } from '../features/admin/admin-service'
import { ScreenHeader } from '../components/ScreenHeader'
import { performAccountLogout } from '../lib/account-exit'
type SavingPreference = 'theme' | 'locale' | 'autoPolish' | 'haptic' | null
@ -60,7 +60,6 @@ const ERROR_KEYS: Record<PreferencesErrorCode, TranslationKey> = {
}
export default function SettingsScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t, locale, setLocale, formatTime } = useI18n()
const { user, purgeLocalSession } = useAuth()
const navigation = useNavigation<any>()
@ -162,20 +161,7 @@ export default function SettingsScreen(): React.ReactElement {
contentContainerStyle={styles.content}
testID="settings-screen"
>
<ThemeText
variant="title"
color="primary"
accessibilityRole="header"
style={[
styles.title,
{
paddingTop: insets.top + 8,
borderBottomColor: palette.border.subtle,
},
]}
>
{t('mobile.set.title')}
</ThemeText>
<ScreenHeader title={t('mobile.set.title')} testID="settings-header" />
<SectionLabel label={t('mobile.set.accountPlan')} />
<ThemeCard style={styles.section}>

View file

@ -45,6 +45,7 @@ import {
import ContentReportSheet, {
type ContentReportTarget,
} from '../components/ContentReportSheet'
import { ScreenHeader } from '../components/ScreenHeader'
interface TalkMessage extends ChatRequestMessage {
id: string
@ -652,23 +653,22 @@ export default function TalkScreen(): React.ReactElement {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={80}
>
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
<View>
<ThemeText variant="title" accessibilityRole="header">{t('mobile.talk.title')}</ThemeText>
<ThemeText variant="label" color={phase === 'idle' ? 'muted' : 'accent'}>
{t(phaseTranslationKey(phase))}
</ThemeText>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.talk.clear')}
onPress={handleClear}
style={styles.clearButton}
testID="talk-clear"
>
<ThemeText variant="label" color="muted">{t('mobile.talk.clear')}</ThemeText>
</Pressable>
</View>
<ScreenHeader
title={t('mobile.talk.title')}
subtitle={t(phaseTranslationKey(phase))}
rightContent={(
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.talk.clear')}
onPress={handleClear}
style={styles.clearButton}
testID="talk-clear"
>
<ThemeText variant="label" color="muted">{t('mobile.talk.clear')}</ThemeText>
</Pressable>
)}
testID="talk-header"
/>
<ScrollView
ref={scrollRef}
@ -712,6 +712,7 @@ export default function TalkScreen(): React.ReactElement {
snapshot: message.content.slice(0, 4_000),
})}
hitSlop={8}
style={styles.bubbleAction}
testID={`talk-report-${message.id}`}
>
<ThemeText variant="label" color="muted">
@ -726,6 +727,7 @@ export default function TalkScreen(): React.ReactElement {
: t('mobile.talk.playSpeech')}
onPress={() => handleSpeechPress(message)}
hitSlop={8}
style={styles.bubbleAction}
testID={`talk-speech-${message.id}`}
>
<ThemeText variant="label" color={speakingMessageId === message.id ? 'accent' : 'muted'}>
@ -879,7 +881,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
borderBottomColor: palette.border.default,
backgroundColor: palette.bg.sidebar,
},
clearButton: { minWidth: 48, minHeight: 44, alignItems: 'center', justifyContent: 'center' },
clearButton: { minWidth: 48, minHeight: 48, alignItems: 'center', justifyContent: 'center' },
chatArea: { flex: 1 },
chatContent: { padding: 20, paddingBottom: 28, gap: 14 },
emptyText: { textAlign: 'center', paddingVertical: 48 },
@ -904,10 +906,11 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
justifyContent: 'space-between',
gap: 16,
},
bubbleLabel: { fontSize: 9, letterSpacing: 1 },
bubbleActions: { flexDirection: 'row', alignItems: 'center', gap: 18 },
bubbleLabel: { fontSize: 11, letterSpacing: 0.6 },
bubbleActions: { flexDirection: 'row', alignItems: 'center', gap: 4 },
bubbleAction: { minWidth: 48, minHeight: 48, alignItems: 'center', justifyContent: 'center' },
busyRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 },
inlineCancel: { minHeight: 36, marginLeft: 'auto', paddingVertical: 4 },
inlineCancel: { minHeight: 48, marginLeft: 'auto', paddingVertical: 4 },
errorCard: {
backgroundColor: palette.bg.card,
borderColor: palette.tag.red,
@ -916,7 +919,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
padding: 14,
gap: 10,
},
retryButton: { alignSelf: 'flex-start', minHeight: 42 },
retryButton: { alignSelf: 'flex-start', minHeight: 48 },
recordingPanel: {
backgroundColor: palette.bg.card,
borderTopWidth: 1,
@ -958,7 +961,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
},
textInput: {
flex: 1,
minHeight: 40,
minHeight: 48,
maxHeight: 120,
color: palette.text.primary,
fontSize: 14,
@ -966,9 +969,9 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
paddingVertical: 8,
},
micButton: {
width: 40,
height: 40,
borderRadius: 20,
width: 48,
height: 48,
borderRadius: 24,
borderWidth: 1,
borderColor: palette.accent.main,
justifyContent: 'center',
@ -976,9 +979,9 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
},
micButtonActive: { backgroundColor: palette.accent.main },
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: palette.accent.main,
justifyContent: 'center',
alignItems: 'center',

View file

@ -18,6 +18,7 @@ import { useI18n } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import { ScreenHeader } from '../components/ScreenHeader'
import {
createTeam,
listTeams,
@ -93,25 +94,22 @@ export default function TeamsScreen({ navigation }: TeamsScreenProps): React.Rea
return (
<View style={styles.container} testID="teams-screen">
<View style={[styles.header, { paddingTop: insets.top + 12 }]}>
<View style={styles.headerCopy}>
<ThemeText variant="title" accessibilityRole="header">
{t('mobile.teams.title')}
</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.teams.description')}
</ThemeText>
</View>
<ThemeButton
label={t('mobile.teams.create')}
onPress={() => {
setName('')
setEditorError(null)
setEditorOpen(true)
}}
testID="teams-create"
/>
</View>
<ScreenHeader
title={t('mobile.teams.title')}
subtitle={t('mobile.teams.description')}
rightContent={(
<ThemeButton
label={t('mobile.teams.create')}
onPress={() => {
setName('')
setEditorError(null)
setEditorOpen(true)
}}
testID="teams-create"
/>
)}
testID="teams-header"
/>
{error !== null && (
<ThemeCard style={styles.errorCard} accessibilityRole="alert" testID="teams-error">

View file

@ -20,10 +20,10 @@ import {
View,
} from 'react-native'
import { useIsFocused } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import type {
DictationTemplateField,
DocumentTemplateType,
TemplateKind,
UserTemplate,
UserTemplateSelection,
@ -31,6 +31,7 @@ import type {
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
import { ScreenHeader } from '../components/ScreenHeader'
import {
createTemplate,
deleteTemplate,
@ -43,6 +44,14 @@ import {
updateTemplate,
} from '../features/templates'
const TEMPLATE_TYPE_KEYS: Record<DocumentTemplateType, TranslationKey> = {
minutes: 'mobile.templates.type.minutes',
report: 'mobile.templates.type.report',
'idea-note': 'mobile.templates.type.ideaNote',
custom: 'mobile.templates.type.custom',
mindmap: 'mobile.templates.type.mindmap',
}
interface TemplatesScreenProps {
initialKind?: TemplateKind
onSelectTemplate?: (template: UserTemplate) => void
@ -123,7 +132,6 @@ export default function TemplatesScreen({
initialKind = 'dictation',
onSelectTemplate,
}: TemplatesScreenProps): React.ReactElement {
const insets = useSafeAreaInsets()
const isFocused = useIsFocused()
const { t } = useI18n()
const { user } = useAuth()
@ -347,7 +355,9 @@ export default function TemplatesScreen({
<ThemeText variant="small" color="muted">
{item.template_kind === 'dictation'
? t('mobile.templates.fieldCount', { count: item.fields.length })
: t('mobile.templates.documentType', { type: item.template_type ?? 'custom' })}
: t('mobile.templates.documentType', {
type: t(TEMPLATE_TYPE_KEYS[item.template_type ?? 'custom']),
})}
</ThemeText>
<View style={styles.actions}>
<ThemeButton
@ -403,13 +413,14 @@ export default function TemplatesScreen({
return (
<View style={styles.container} testID="templates-screen">
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
<View style={styles.headerText}>
<ThemeText variant="title" accessibilityRole="header">{t('mobile.templates.title')}</ThemeText>
<ThemeText variant="small" color="muted">{t('mobile.templates.description')}</ThemeText>
</View>
<ThemeButton label={t('mobile.templates.add')} onPress={openCreate} testID="templates-add" />
</View>
<ScreenHeader
title={t('mobile.templates.title')}
subtitle={t('mobile.templates.description')}
rightContent={(
<ThemeButton label={t('mobile.templates.add')} onPress={openCreate} testID="templates-add" />
)}
testID="templates-header"
/>
{!realtimeConnected && (
<View style={styles.banner} testID="templates-realtime-warning">
<ThemeText variant="small" color="muted">{t('mobile.templates.realtimeWaiting')}</ThemeText>
@ -669,7 +680,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
banner: { paddingHorizontal: 20, paddingVertical: 8, backgroundColor: palette.accent.dim },
controls: { padding: 16, gap: 10, borderBottomWidth: 1, borderBottomColor: palette.border.subtle },
tabs: { flexDirection: 'row', gap: 8 },
tab: { flex: 1, minHeight: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 9, borderWidth: 1, borderColor: palette.border.default },
tab: { flex: 1, minHeight: 48, alignItems: 'center', justifyContent: 'center', borderRadius: 9, borderWidth: 1, borderColor: palette.border.default },
tabActive: { backgroundColor: palette.accent.main, borderColor: palette.accent.main },
input: { minHeight: 48, borderWidth: 1, borderColor: palette.border.default, borderRadius: 10, backgroundColor: palette.bg.inset, color: palette.text.primary, paddingHorizontal: 12, fontSize: 14 },
inlineError: { padding: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10, borderBottomWidth: 1, borderBottomColor: palette.tag.red },

View file

@ -0,0 +1,276 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
StyleSheet,
TextInput,
View,
} from 'react-native'
import type { RouteProp } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { UnifiedSearchMatchedField, UnifiedSearchResult } from '@d3ro/core/types'
import {
NativeThemeProvider,
PhosphorText,
PhysicalButton,
d3roNativeControl,
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { UnifiedSearchResultRow } from '../components/UnifiedSearchResultRow'
import { normalizeUnifiedSearchInitialQuery } from '../features/search/unified-search-route'
import { searchUnifiedContent } from '../features/search/unified-search-service'
import { useAuth } from '../lib/auth-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ScreenHeader } from '../components/ScreenHeader'
import type { RootStackParamList } from '../../App'
interface UnifiedSearchScreenProps {
navigation: NativeStackNavigationProp<RootStackParamList, 'UnifiedSearch'>
route: RouteProp<RootStackParamList, 'UnifiedSearch'>
}
type ScreenState =
| { status: 'idle'; results: UnifiedSearchResult[]; total: number }
| { status: 'loading'; results: UnifiedSearchResult[]; total: number }
| { status: 'ready'; results: UnifiedSearchResult[]; total: number }
| { status: 'error'; results: UnifiedSearchResult[]; total: number }
const SEARCH_DEBOUNCE_MS = 250
export default function UnifiedSearchScreen({
navigation,
route,
}: UnifiedSearchScreenProps): React.ReactElement {
const insets = useSafeAreaInsets()
const { t, formatDate } = useI18n()
const { user } = useAuth()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
const [query, setQuery] = useState(() => (
normalizeUnifiedSearchInitialQuery(route.params?.initialQuery)
))
const [retryNonce, setRetryNonce] = useState(0)
const [state, setState] = useState<ScreenState>({ status: 'idle', results: [], total: 0 })
const requestSequence = useRef(0)
useEffect(() => {
const normalized = query.trim()
if (!normalized) {
requestSequence.current += 1
setState({ status: 'idle', results: [], total: 0 })
return undefined
}
if (user === null) {
requestSequence.current += 1
setState({ status: 'error', results: [], total: 0 })
return undefined
}
const sequence = ++requestSequence.current
const timer = setTimeout(() => {
setState({ status: 'loading', results: [], total: 0 })
void searchUnifiedContent({ userId: user.id, query: normalized, limit: 40 })
.then((response) => {
if (requestSequence.current !== sequence) return
setState({ status: 'ready', results: response.results, total: response.total })
})
.catch(() => {
if (requestSequence.current !== sequence) return
setState((current) => ({ ...current, status: 'error' }))
})
}, SEARCH_DEBOUNCE_MS)
return () => {
clearTimeout(timer)
if (requestSequence.current === sequence) requestSequence.current += 1
}
}, [query, retryNonce, user])
const openResult = (result: UnifiedSearchResult): void => {
if (result.source === 'record') {
navigation.navigate('HistoryDetail', { historyId: result.id })
return
}
navigation.navigate('MeetingDetail', { meetingId: result.id })
}
const matchLabel = (field: UnifiedSearchMatchedField): string => {
switch (field) {
case 'title':
return t('unifiedSearch.matchedTitle')
case 'content':
return t('unifiedSearch.matchedContent')
case 'transcript':
return t('unifiedSearch.matchedTranscript')
}
}
const errorDescription = user === null
? t('unifiedSearch.signInRequired')
: t('unifiedSearch.loadErrorDescription')
return (
<NativeThemeProvider palette={palette}>
<View style={styles.container}>
<ScreenHeader
title={t('unifiedSearch.title')}
subtitle={t('unifiedSearch.description')}
onBack={() => navigation.goBack()}
testID="unified-search-header"
/>
<View style={styles.searchWrap}>
<TextInput
accessibilityLabel={t('unifiedSearch.placeholder')}
value={query}
onChangeText={setQuery}
placeholder={t('unifiedSearch.placeholder')}
placeholderTextColor={palette.text.tertiary}
autoFocus
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
maxLength={100}
style={styles.searchInput}
testID="unified-search-input"
/>
</View>
{state.status === 'loading' && state.results.length === 0 ? (
<View
accessibilityLiveRegion="polite"
style={styles.center}
testID="unified-search-loading"
>
<ActivityIndicator size="large" color={palette.accent.main} />
<PhosphorText color="tertiary">{t('unifiedSearch.loading')}</PhosphorText>
</View>
) : state.status === 'error' ? (
<View
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
style={styles.center}
testID="unified-search-error"
>
<PhosphorText variant="heading" color="primary" style={styles.centerText}>
{t('unifiedSearch.loadErrorTitle')}
</PhosphorText>
<PhosphorText color="secondary" style={styles.centerText}>
{errorDescription}
</PhosphorText>
{user !== null && (
<PhysicalButton
label={t('unifiedSearch.retry')}
tone="glass"
onPress={() => setRetryNonce((value) => value + 1)}
testID="unified-search-retry"
/>
)}
</View>
) : state.status === 'idle' ? (
<View style={styles.center} testID="unified-search-prompt">
<PhosphorText variant="heading" color="primary" style={styles.centerText}>
{t('unifiedSearch.promptTitle')}
</PhosphorText>
<PhosphorText color="secondary" style={styles.centerText}>
{t('unifiedSearch.promptDescription')}
</PhosphorText>
</View>
) : state.results.length === 0 ? (
<View
accessibilityLiveRegion="polite"
style={styles.center}
testID="unified-search-empty"
>
<PhosphorText variant="heading" color="primary" style={styles.centerText}>
{t('unifiedSearch.noResultsTitle')}
</PhosphorText>
<PhosphorText color="secondary" style={styles.centerText}>
{t('unifiedSearch.noResultsDescription', { query: query.trim() })}
</PhosphorText>
</View>
) : (
<FlatList
data={state.results}
keyExtractor={(result) => `${result.source}:${result.id}`}
contentContainerStyle={[styles.list, { paddingBottom: Math.max(insets.bottom, 24) }]}
keyboardShouldPersistTaps="handled"
ListHeaderComponent={(
<View accessibilityLiveRegion="polite" style={styles.resultCount}>
<PhosphorText variant="meta" color="tertiary">
{t('unifiedSearch.resultCount', { count: state.total })}
</PhosphorText>
</View>
)}
renderItem={({ item }) => {
const sourceLabel = item.source === 'record'
? t('unifiedSearch.sourceRecord')
: t('unifiedSearch.sourceMeeting')
const title = item.title?.trim() || (item.source === 'record'
? t('unifiedSearch.untitledRecord')
: t('unifiedSearch.untitledMeeting'))
return (
<UnifiedSearchResultRow
result={item}
title={title}
sourceLabel={sourceLabel}
matchLabel={matchLabel(item.matchedField)}
dateLabel={formatDate(item.occurredAt, { dateStyle: 'medium' })}
previewFallback={t('unifiedSearch.noPreview')}
accessibilityLabel={t('unifiedSearch.openResult', { source: sourceLabel, title })}
onPress={openResult}
/>
)
}}
/>
)}
</View>
</NativeThemeProvider>
)
}
type Palette = ReturnType<typeof useMobilePreferences>['palette']
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: palette.bg.app,
},
searchWrap: {
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 12,
},
searchInput: {
minHeight: d3roNativeControl.h,
borderWidth: 1,
borderColor: palette.border.strong,
borderRadius: 8,
paddingHorizontal: 14,
backgroundColor: palette.bg.input,
color: palette.text.primary,
fontSize: 14,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
paddingHorizontal: 32,
paddingBottom: 24,
},
centerText: {
textAlign: 'center',
maxWidth: 440,
},
list: {
paddingHorizontal: 20,
},
resultCount: {
minHeight: 36,
justifyContent: 'center',
},
})
}

View file

@ -1,515 +0,0 @@
// src/screens/WorksHubScreen.tsx — WORKS 허브: HIST 탭 강등 + 데스크톱 패리티 기능 드릴다운
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { NavigationProp, ParamListBase } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
AppStatusBar,
Header,
NativeThemeProvider,
PhosphorText,
} from '@d3ro/ui-native'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { useMobilePreferences } from '../lib/preferences-context'
const PINS_STORAGE_KEY = 'works-hub-pins.v1'
interface WorksFeature {
id: string
route: string
titleKey: TranslationKey
descriptionKey: TranslationKey
testID: string
icon: IconType
}
type IconType =
| 'meetings'
| 'history'
| 'memos'
| 'templates'
| 'teams'
| 'knowledge'
| 'dictionary'
| 'commands'
| 'actions'
| 'notifications'
| 'portability'
type SectionId = 'meetings' | 'team' | 'data'
const WORKS_SECTIONS: Array<{ id: SectionId; labelKey: TranslationKey; features: WorksFeature[] }> = [
{
id: 'meetings',
labelKey: 'mobile.works.section.meetings',
features: [
{
id: 'meetings',
route: 'Meetings',
titleKey: 'mobile.meetings.title',
descriptionKey: 'mobile.meetings.description',
testID: 'open-meetings',
icon: 'meetings',
},
{
id: 'history',
route: 'History',
titleKey: 'mobile.hist.title',
descriptionKey: 'mobile.works.historyDescription',
testID: 'open-history',
icon: 'history',
},
{
id: 'memos',
route: 'Memos',
titleKey: 'mobile.memos.title',
descriptionKey: 'mobile.memos.description',
testID: 'open-memos',
icon: 'memos',
},
{
id: 'templates',
route: 'Templates',
titleKey: 'mobile.templates.title',
descriptionKey: 'mobile.templates.description',
testID: 'open-templates',
icon: 'templates',
},
],
},
{
id: 'team',
labelKey: 'mobile.works.section.team',
features: [
{
id: 'teams',
route: 'Teams',
titleKey: 'mobile.teams.title',
descriptionKey: 'mobile.teams.description',
testID: 'open-teams',
icon: 'teams',
},
{
id: 'knowledge',
route: 'Knowledge',
titleKey: 'mobile.knowledge.title',
descriptionKey: 'mobile.knowledge.description',
testID: 'open-knowledge',
icon: 'knowledge',
},
{
id: 'dictionary',
route: 'Dictionary',
titleKey: 'mobile.dictionary.title',
descriptionKey: 'mobile.dictionary.description',
testID: 'open-dictionary',
icon: 'dictionary',
},
{
id: 'commands',
route: 'Commands',
titleKey: 'mobile.commands.title',
descriptionKey: 'mobile.commands.description',
testID: 'open-commands',
icon: 'commands',
},
{
id: 'actions',
route: 'Actions',
titleKey: 'mobile.actions.title',
descriptionKey: 'mobile.actions.description',
testID: 'open-actions',
icon: 'actions',
},
],
},
{
id: 'data',
labelKey: 'mobile.works.section.data',
features: [
{
id: 'notifications',
route: 'Notifications',
titleKey: 'mobile.notifications.title',
descriptionKey: 'mobile.notifications.description',
testID: 'open-notifications',
icon: 'notifications',
},
{
id: 'portability',
route: 'DataPortability',
titleKey: 'mobile.portability.title',
descriptionKey: 'mobile.portability.description',
testID: 'open-data-portability',
icon: 'portability',
},
],
},
]
const ALL_FEATURES: WorksFeature[] = WORKS_SECTIONS.flatMap((section) => section.features)
function HubGlyph({ type, color }: { type: IconType; color: string }): React.ReactElement {
return (
<View style={[glyphStyles.frame, { borderColor: color }]}>
{type === 'meetings' && (
<>
<View style={[glyphStyles.bar, { backgroundColor: color, width: 3, height: 9 }]} />
<View style={[glyphStyles.bar, { backgroundColor: color, width: 3, height: 6 }]} />
</>
)}
{type === 'history' && (
<>
<View style={[glyphStyles.hand, { backgroundColor: color }]} />
<View style={[glyphStyles.handWide, { backgroundColor: color }]} />
</>
)}
{type === 'memos' && (
<View style={glyphStyles.stack}>
<View style={[glyphStyles.line, { backgroundColor: color, width: 11 }]} />
<View style={[glyphStyles.line, { backgroundColor: color, width: 8 }]} />
<View style={[glyphStyles.line, { backgroundColor: color, width: 11 }]} />
</View>
)}
{type === 'templates' && (
<>
<View style={[glyphStyles.line, { backgroundColor: color, width: 12 }]} />
<View style={[glyphStyles.line, { backgroundColor: color, width: 7, alignSelf: 'flex-start' }]} />
</>
)}
{type === 'teams' && (
<>
<View style={[glyphStyles.dot, { backgroundColor: color }]} />
<View style={[glyphStyles.dot, { backgroundColor: color }]} />
</>
)}
{type === 'knowledge' && (
<>
<View style={[glyphStyles.ring, { borderColor: color }]} />
<View style={[glyphStyles.dot, { backgroundColor: color }]} />
</>
)}
{type === 'dictionary' && (
<View style={[glyphStyles.spine, { borderColor: color }]} />
)}
{type === 'commands' && (
<>
<View style={[glyphStyles.chevronUp, { borderColor: color }]} />
<View style={[glyphStyles.chevronDown, { borderColor: color }]} />
</>
)}
{type === 'actions' && (
<View style={[glyphStyles.bolt, { backgroundColor: color }]} />
)}
{type === 'notifications' && (
<>
<View style={[glyphStyles.dome, { borderColor: color }]} />
<View style={[glyphStyles.dot, { backgroundColor: color }]} />
</>
)}
{type === 'portability' && (
<>
<View style={[glyphStyles.arrowShaft, { backgroundColor: color }]} />
<View style={[glyphStyles.arrowHead, { borderColor: color }]} />
</>
)}
</View>
)
}
interface WorksHubScreenProps {
navigation: NavigationProp<ParamListBase>
}
export default function WorksHubScreen({ navigation }: WorksHubScreenProps): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
const [search, setSearch] = useState('')
const [pins, setPins] = useState<string[]>([])
useEffect(() => {
let active = true
void AsyncStorage.getItem(PINS_STORAGE_KEY)
.then((raw) => {
if (!active || raw === null) return
const parsed: unknown = JSON.parse(raw)
if (Array.isArray(parsed)) {
setPins(parsed.filter((id): id is string => typeof id === 'string'))
}
})
.catch(() => undefined)
return () => { active = false }
}, [])
const togglePin = useCallback((id: string): void => {
setPins((previous) => {
const next = previous.includes(id)
? previous.filter((pinned) => pinned !== id)
: [...previous, id]
void AsyncStorage.setItem(PINS_STORAGE_KEY, JSON.stringify(next)).catch(() => undefined)
return next
})
}, [])
const normalizedSearch = search.trim().toLowerCase()
const searchMatches = useCallback(
(feature: WorksFeature): boolean => {
if (normalizedSearch.length === 0) return true
const haystack = `${t(feature.titleKey)} ${t(feature.descriptionKey)}`.toLowerCase()
return haystack.includes(normalizedSearch)
},
[normalizedSearch, t],
)
const pinnedFeatures = useMemo(
() => ALL_FEATURES.filter((feature) => pins.includes(feature.id) && searchMatches(feature)),
[pins, searchMatches],
)
const searching = normalizedSearch.length > 0
const visibleSections = useMemo(
() => WORKS_SECTIONS
.map((section) => ({
...section,
features: section.features.filter(searchMatches),
}))
.filter((section) => section.features.length > 0),
[searchMatches],
)
const totalVisible = pinnedFeatures.length
+ visibleSections.reduce((sum, section) => sum + section.features.length, 0)
const openFeature = (feature: WorksFeature): void => {
navigation.navigate(feature.route)
}
const renderFeatureRow = (feature: WorksFeature): React.ReactElement => {
const pinned = pins.includes(feature.id)
return (
<FeatureRow
key={feature.id}
feature={feature}
pinned={pinned}
onPress={() => openFeature(feature)}
onTogglePin={() => togglePin(feature.id)}
/>
)
}
return (
<NativeThemeProvider palette={palette}>
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 110 }]}
keyboardShouldPersistTaps="handled"
testID="works-screen"
>
<Header
title={t('mobile.works.title')}
rightContent={
<PhosphorText variant="meta" color="muted">
{t('mobile.works.count', { count: totalVisible })}
</PhosphorText>
}
paddingTop={insets.top}
/>
<View style={styles.searchRow}>
<TextInput
accessibilityLabel={t('mobile.works.searchPlaceholder')}
value={search}
onChangeText={setSearch}
placeholder={t('mobile.works.searchPlaceholder')}
placeholderTextColor={palette.text.muted}
returnKeyType="search"
autoCapitalize="none"
autoCorrect={false}
style={styles.searchInput}
testID="works-search"
/>
</View>
{pinnedFeatures.length > 0 && (
<>
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
{t('mobile.works.pinned')}
</PhosphorText>
{pinnedFeatures.map(renderFeatureRow)}
</>
)}
{searching && totalVisible === 0 && (
<PhosphorText variant="body" color="muted" style={styles.emptyText} testID="works-empty">
{t('mobile.works.emptySearch')}
</PhosphorText>
)}
{visibleSections.map((section) => (
<View key={section.id}>
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
{t(section.labelKey)}
</PhosphorText>
{section.features.map(renderFeatureRow)}
</View>
))}
<AppStatusBar
model={t('mobile.status.cloudModel')}
label={t('mobile.status.secureDataLink')}
/>
</ScrollView>
</NativeThemeProvider>
)
}
function FeatureRow({
feature,
pinned,
onPress,
onTogglePin,
}: {
feature: WorksFeature
pinned: boolean
onPress: () => void
onTogglePin: () => void
}): React.ReactElement {
const { palette } = useMobilePreferences()
const { t } = useI18n()
const styles = useMemo(() => createStyles(palette), [palette])
return (
<View
style={[
styles.featureCard,
{ borderColor: palette.border.default, backgroundColor: palette.bg.card },
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={t(feature.titleKey)}
accessibilityHint={t(feature.descriptionKey)}
testID={feature.testID}
onPress={onPress}
style={({ pressed }) => [
styles.featureBody,
{ backgroundColor: pressed ? palette.bg.cardHover : 'transparent' },
]}
>
<HubGlyph type={feature.icon} color={palette.accent.main} />
<View style={styles.featureText}>
<PhosphorText variant="body" color="primary" numberOfLines={1}>
{t(feature.titleKey)}
</PhosphorText>
<PhosphorText variant="small" color="muted" numberOfLines={2}>
{t(feature.descriptionKey)}
</PhosphorText>
</View>
<PhosphorText variant="heading" color="amber">→</PhosphorText>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={pinned ? t('mobile.works.unpin') : t('mobile.works.pin')}
accessibilityHint={t(feature.titleKey)}
testID={`works-pin-${feature.id}`}
onPress={onTogglePin}
style={({ pressed }) => [
styles.pinButton,
{ borderLeftColor: palette.border.subtle, opacity: pressed ? 0.6 : 1 },
]}
>
<PhosphorText variant="small" color={pinned ? 'amber' : 'muted'}>
{pinned ? '★' : '☆'}
</PhosphorText>
</Pressable>
</View>
)
}
type Palette = ReturnType<typeof useMobilePreferences>['palette']
function createStyles(palette: Palette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: palette.bg.app },
content: { paddingBottom: 120 },
searchRow: { paddingHorizontal: 20, paddingTop: 14, paddingBottom: 6 },
searchInput: {
minHeight: 48,
paddingHorizontal: 14,
borderWidth: 1,
borderColor: palette.border.default,
borderRadius: 10,
backgroundColor: palette.bg.inset,
color: palette.text.primary,
fontSize: 14,
},
sectionLabel: { letterSpacing: 3, paddingHorizontal: 20, paddingTop: 18, paddingBottom: 8 },
featureCard: {
flexDirection: 'row',
marginHorizontal: 20,
marginBottom: 10,
borderWidth: 1,
borderRadius: 12,
overflow: 'hidden',
},
featureBody: {
flex: 1,
minHeight: 64,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 14,
},
featureText: { flex: 1, gap: 4 },
pinButton: {
width: 52,
minHeight: 48,
alignItems: 'center',
justifyContent: 'center',
borderLeftWidth: 1,
},
emptyText: { textAlign: 'center', padding: 32 },
})
}
const glyphStyles = StyleSheet.create({
frame: {
width: 24,
height: 24,
borderWidth: 1.5,
borderRadius: 6,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
},
bar: { borderRadius: 1 },
hand: { width: 1.5, height: 7, position: 'absolute', top: 5 },
handWide: { width: 5, height: 1.5, position: 'absolute', top: 8, left: 6 },
stack: { alignItems: 'flex-start', gap: 2 },
line: { height: 1.5, borderRadius: 1 },
dot: { width: 4, height: 4, borderRadius: 2 },
ring: { width: 9, height: 9, borderWidth: 1.5, borderRadius: 5, position: 'absolute' },
spine: { width: 7, height: 11, borderWidth: 1.5, borderLeftWidth: 0, borderRadius: 2 },
chevronUp: {
width: 6, height: 6, borderTopWidth: 1.5, borderRightWidth: 1.5,
transform: [{ rotate: '-45deg' }], marginBottom: -2,
},
chevronDown: {
width: 6, height: 6, borderTopWidth: 1.5, borderRightWidth: 1.5,
transform: [{ rotate: '135deg' }], marginTop: -2,
},
bolt: { width: 6, height: 12, borderRadius: 2, marginTop: 2 },
dome: { width: 10, height: 7, borderTopWidth: 1.5, borderLeftWidth: 1.5, borderRightWidth: 1.5, borderTopLeftRadius: 6, borderTopRightRadius: 6, marginBottom: 3 },
arrowShaft: { width: 10, height: 1.5, borderRadius: 1 },
arrowHead: {
width: 6, height: 6, borderTopWidth: 1.5, borderRightWidth: 1.5,
transform: [{ rotate: '45deg' }], marginLeft: -4,
},
})

View file

@ -1,184 +1,24 @@
import type { ColorSchemeName } from 'react-native'
import {
getD3roNativePalette,
type D3roNativePalette,
} from '@d3ro/ui-native'
export type MobileThemeMode = 'system' | 'light' | 'dark'
export type EffectiveMobileTheme = 'light' | 'dark'
/**
* The mobile app and @d3ro/ui-native intentionally share one palette contract.
* Keeping this alias prevents a second, drifting copy of the v4 theme.
*/
export type MobileThemePalette = D3roNativePalette
export const MOBILE_THEME_MODES: readonly MobileThemeMode[] = [
'system',
'light',
'dark',
] as const
export interface MobileThemePalette {
bg: {
app: string
card: string
cardHover: string
elevated: string
sidebar: string
inset: string
chassis: string
}
text: {
primary: string
white: string
onAccent: string
secondary: string
label: string
disabled: string
inactive: string
muted: string
}
accent: {
main: string
pressed: string
dim: string
glow: string
green: string
greenGlow: string
}
border: {
subtle: string
default: string
strong: string
}
tag: {
purple: string
orange: string
red: string
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
}
shadow: string
}
const DARK_PALETTE: MobileThemePalette = {
bg: {
app: '#19191b',
card: '#242427',
cardHover: '#2a2a2d',
elevated: '#2e2e32',
sidebar: '#1e1f21',
inset: '#0f0f11',
chassis: '#242528',
},
text: {
primary: '#f4f4f5',
white: '#ffffff',
onAccent: '#ffffff',
secondary: '#b4b4bb',
label: '#a1a1aa',
disabled: '#5f6066',
inactive: '#8d8e94',
muted: '#9b9ba3',
},
accent: {
// 브랜드 통일: 앱(Midnight Glass) blue 토큰과 동일 값
main: '#3b82f6',
pressed: '#2563eb',
dim: 'rgba(59, 130, 246, 0.16)',
glow: 'rgba(59, 130, 246, 0.5)',
green: '#4ade80',
greenGlow: 'rgba(74, 222, 128, 0.55)',
},
border: {
subtle: 'rgba(255, 255, 255, 0.08)',
default: '#3a3a40',
strong: 'rgba(255, 255, 255, 0.22)',
},
tag: {
purple: '#c084fc',
orange: '#fbbf24',
red: '#f87171',
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',
}
const LIGHT_PALETTE: MobileThemePalette = {
bg: {
app: '#f5f5f4',
card: '#ffffff',
cardHover: '#f1f1ef',
elevated: '#ececea',
sidebar: '#efefed',
inset: '#e7e7e4',
chassis: '#dededb',
},
text: {
primary: '#1c1c1e',
white: '#ffffff',
onAccent: '#ffffff',
secondary: '#45454b',
label: '#55555d',
disabled: '#99999f',
inactive: '#6f6f76',
muted: '#626269',
},
accent: {
main: '#2563eb',
pressed: '#1d4ed8',
dim: 'rgba(37, 99, 235, 0.10)',
glow: 'rgba(37, 99, 235, 0.24)',
green: '#15803d',
greenGlow: 'rgba(21, 128, 61, 0.22)',
},
border: {
subtle: 'rgba(28, 28, 30, 0.10)',
default: '#cececb',
strong: 'rgba(28, 28, 30, 0.30)',
},
tag: {
purple: '#7e22ce',
orange: '#a25200',
red: '#b42318',
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',
}
export function isMobileThemeMode(value: unknown): value is MobileThemeMode {
return typeof value === 'string'
&& (MOBILE_THEME_MODES as readonly string[]).includes(value)
@ -195,5 +35,5 @@ export function resolveEffectiveTheme(
export function getMobileThemePalette(
effectiveTheme: EffectiveMobileTheme,
): MobileThemePalette {
return effectiveTheme === 'light' ? LIGHT_PALETTE : DARK_PALETTE
return getD3roNativePalette(effectiveTheme)
}

View file

@ -9,12 +9,20 @@ import {
type ViewProps,
type ViewStyle,
} from 'react-native'
import { d3roNativeFonts, d3roNativeRadius, d3roNativeTypo, type D3roNativeTypoKey } from '@d3ro/ui-native'
import {
d3roNativeControl,
d3roNativeFonts,
d3roNativeRadius,
d3roNativeTypo,
type D3roNativePalette,
type D3roNativeTypoKey,
} from '@d3ro/ui-native'
import { useMobilePreferences } from '../lib/preferences-context'
type ThemeTextColor =
| 'primary'
| 'secondary'
| 'tertiary'
| 'muted'
| 'accent'
| 'success'
@ -38,11 +46,12 @@ export function ThemeText({
const resolvedColor: Record<ThemeTextColor, string> = {
primary: palette.text.primary,
secondary: palette.text.secondary,
muted: palette.text.muted,
accent: palette.accent.main,
success: palette.tag.green,
danger: palette.tag.red,
onAccent: palette.text.onAccent,
tertiary: palette.text.tertiary,
muted: palette.text.tertiary,
accent: palette.accent.light,
success: palette.status?.success ?? palette.tag.green,
danger: palette.status?.danger ?? palette.tag.red,
onAccent: palette.accent.ink,
}
return (
@ -82,16 +91,10 @@ export function ThemeCard({
style={[
{
backgroundColor: inset ? palette.bg.inset : palette.bg.card,
borderColor: palette.border.subtle,
borderWidth: inset ? 0 : 1,
// 카드 반경은 SSOT 토큰 사용
borderColor: inset ? palette.border.subtle : palette.border.default,
borderWidth: 1,
borderRadius: inset ? d3roNativeRadius.inner : d3roNativeRadius.card,
padding: inset ? 10 : 16,
shadowColor: palette.shadow,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: inset ? 0 : 0.12,
shadowRadius: inset ? 0 : 8,
elevation: inset ? 0 : 2,
padding: inset ? 12 : 16,
},
style,
]}
@ -108,6 +111,49 @@ interface ThemeButtonProps extends Omit<PressableProps, 'children' | 'style'> {
textStyle?: StyleProp<TextStyle>
}
interface ThemeButtonTone {
background: string
pressed: string
border: string
text: ThemeTextColor
}
export function resolveThemeButtonTone(
palette: D3roNativePalette,
variant: NonNullable<ThemeButtonProps['variant']>,
): ThemeButtonTone {
switch (variant) {
case 'primary':
return {
background: palette.accent.solid,
pressed: palette.accent.solidHover,
border: palette.accent.solid,
text: 'onAccent',
}
case 'danger':
return {
background: 'transparent',
pressed: palette.status.dangerBg,
border: palette.border.strong,
text: 'danger',
}
case 'quiet':
return {
background: 'transparent',
pressed: palette.overlay.soft,
border: 'transparent',
text: 'secondary',
}
case 'secondary':
return {
background: 'transparent',
pressed: palette.overlay.soft,
border: palette.border.strong,
text: 'primary',
}
}
}
export function ThemeButton({
label,
variant = 'primary',
@ -117,51 +163,38 @@ export function ThemeButton({
...pressableProps
}: ThemeButtonProps): React.ReactElement {
const { palette } = useMobilePreferences()
const primary = variant === 'primary'
const danger = variant === 'danger'
const quiet = variant === 'quiet'
const backgroundColor = primary
? palette.accent.main
: danger
? palette.tag.red
: 'transparent'
const borderColor = quiet
? 'transparent'
: primary
? palette.accent.main
: danger
? palette.tag.red
: palette.border.strong
const tone = resolveThemeButtonTone(palette, variant)
const isDisabled = disabled === true
return (
<Pressable
{...pressableProps}
accessibilityRole={pressableProps.accessibilityRole ?? 'button'}
disabled={disabled}
accessibilityState={{
...pressableProps.accessibilityState,
disabled: isDisabled,
}}
disabled={isDisabled}
style={({ pressed }) => [
{
minHeight: 48,
minHeight: d3roNativeControl.h,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 18,
paddingHorizontal: d3roNativeControl.padX,
paddingVertical: 12,
borderRadius: d3roNativeRadius.button,
borderWidth: quiet ? 0 : 1,
borderColor,
backgroundColor: pressed && primary
? palette.accent.pressed
: pressed && !primary
? palette.bg.cardHover
: backgroundColor,
opacity: disabled ? 0.48 : 1,
borderWidth: 1,
borderColor: tone.border,
backgroundColor: pressed && !isDisabled ? tone.pressed : tone.background,
opacity: isDisabled ? 0.5 : 1,
},
style,
]}
>
<ThemeText
variant="small"
color={primary || danger ? 'onAccent' : quiet ? 'muted' : 'primary'}
style={[{ textAlign: 'center' }, textStyle]}
color={tone.text}
style={[{ textAlign: 'center', fontWeight: '500' }, textStyle]}
>
{label}
</ThemeText>