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

@ -30,6 +30,7 @@ import { registerAdsHandlers } from './ads-handlers'
import { registerSupportHandlers } from './support-handlers'
import { registerInputTelemetryHandlers } from './input-telemetry-handlers'
import { registerSuggestionHandlers } from './suggestion-handlers'
import { registerUnifiedSearchHandlers } from './unified-search-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
@ -44,6 +45,7 @@ export function registerAllIpcHandlers(): void {
registerKeyBindingHandlers()
registerLLMHandlers()
registerHistoryHandlers()
registerUnifiedSearchHandlers()
registerDictionaryHandlers()
registerInstructionHandlers()
registerMemoHandlers()

View file

@ -0,0 +1,19 @@
import { ipcMain } from 'electron'
import { D3ROError, ErrorCode, ipcError, ipcSuccess } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
getUnifiedSearchService,
validateUnifiedSearchParams,
} from '../services/UnifiedSearchService'
export function registerUnifiedSearchHandlers(): void {
ipcMain.handle(IPC_CHANNELS.UNIFIED_SEARCH.QUERY, (_event, params: unknown) => {
try {
const validatedParams = validateUnifiedSearchParams(params)
return ipcSuccess(getUnifiedSearchService().search(validatedParams))
} catch (error) {
if (error instanceof D3ROError) return ipcError(error.code, error.message, error.details)
return ipcError(ErrorCode.DBQueryFailed, 'Unified search failed')
}
})
}

View file

@ -0,0 +1,182 @@
// 기록과 회의를 스키마 변경 없이 함께 찾는 로컬 통합 검색.
import { EventEmitter } from 'events'
import { count, desc, or } from 'drizzle-orm'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
UnifiedSearchParams,
UnifiedSearchResponse,
UnifiedSearchResult,
} from '@d3ro/core/types'
import { getDatabase } from '../db'
import { containsPattern } from '../db/like-pattern'
import { history, meetingSessions, type History, type MeetingSessionRow } from '../db/schema'
import { getLogger } from './LoggerService'
const logger = getLogger('UnifiedSearchService')
const DEFAULT_LIMIT = 30
const MAX_LIMIT = 50
const MAX_QUERY_LENGTH = 200
const EXCERPT_LENGTH = 180
export function validateUnifiedSearchParams(value: unknown): UnifiedSearchParams {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search parameters are required')
}
const candidate = value as Record<string, unknown>
if (typeof candidate.query !== 'string') {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search query must be a string', {
field: 'query',
})
}
if (
candidate.limit !== undefined
&& (typeof candidate.limit !== 'number' || !Number.isFinite(candidate.limit))
) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search limit must be a finite number', {
field: 'limit',
})
}
return candidate.limit === undefined
? { query: candidate.query }
: { query: candidate.query, limit: candidate.limit as number }
}
export function normalizeUnifiedSearchQuery(value: string): string {
return value.slice(0, MAX_QUERY_LENGTH).trim().replace(/\s+/g, ' ')
}
function includesQuery(value: string | null | undefined, query: string): boolean {
return value?.toLocaleLowerCase().includes(query.toLocaleLowerCase()) === true
}
export function createUnifiedSearchExcerpt(
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 body = text.slice(start, end).trim()
return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`
}
function recordResult(row: History, query: string): UnifiedSearchResult {
const titleMatched = includesQuery(row.title, query)
const matchingContent = [row.polishedText, row.originalText, row.summaryText]
.find((value) => includesQuery(value, query))
const displayContent = matchingContent ?? row.polishedText ?? row.originalText ?? row.summaryText ?? row.title
return {
source: 'record',
id: row.id,
title: row.title,
excerpt: createUnifiedSearchExcerpt(displayContent, query),
matchedField: titleMatched ? 'title' : 'content',
occurredAt: row.createdAt,
}
}
function meetingResult(row: MeetingSessionRow, query: string): UnifiedSearchResult {
const titleMatched = includesQuery(row.title, query)
const matchingTranscript = [row.editedTranscript, row.rawTranscript]
.find((value) => includesQuery(value, query))
const displayContent = matchingTranscript ?? row.editedTranscript ?? row.rawTranscript ?? row.title
return {
source: 'meeting',
id: row.id,
title: row.title,
excerpt: createUnifiedSearchExcerpt(displayContent, query),
matchedField: titleMatched ? 'title' : 'transcript',
occurredAt: row.startedAt,
}
}
export function mergeUnifiedSearchResults(
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)
}
class UnifiedSearchService extends EventEmitter {
search(value: unknown): UnifiedSearchResponse {
const params = validateUnifiedSearchParams(value)
const query = normalizeUnifiedSearchQuery(params.query)
const limit = Math.max(1, Math.min(Math.floor(params.limit ?? DEFAULT_LIMIT), MAX_LIMIT))
if (query.length === 0) return { query, results: [], total: 0 }
try {
const db = getDatabase()
const pattern = containsPattern(query)
const recordConditions = or(
pattern.match(history.title),
pattern.match(history.originalText),
pattern.match(history.polishedText),
pattern.match(history.summaryText),
)
const meetingConditions = or(
pattern.match(meetingSessions.title),
pattern.match(meetingSessions.editedTranscript),
pattern.match(meetingSessions.rawTranscript),
)
const recordTotal = db.select({ count: count() }).from(history).where(recordConditions).get()?.count ?? 0
const recordRows = db
.select()
.from(history)
.where(recordConditions)
.orderBy(desc(history.createdAt))
.limit(limit)
.all()
const meetingTotal = db.select({ count: count() }).from(meetingSessions).where(meetingConditions).get()?.count ?? 0
const meetingRows = db
.select()
.from(meetingSessions)
.where(meetingConditions)
.orderBy(desc(meetingSessions.startedAt))
.limit(limit)
.all()
const results = mergeUnifiedSearchResults(
recordRows.map((row) => recordResult(row, query)),
meetingRows.map((row) => meetingResult(row, query)),
limit,
)
return { query, results, total: recordTotal + meetingTotal }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`Unified search failed: ${message}`)
throw new D3ROError(ErrorCode.DBQueryFailed, 'Unified search failed', { cause: message })
}
}
dispose(): void {
this.removeAllListeners()
}
}
let instance: UnifiedSearchService | null = null
export function getUnifiedSearchService(): UnifiedSearchService {
if (instance === null) instance = new UnifiedSearchService()
return instance
}
export function resetUnifiedSearchServiceForTests(): void {
instance?.dispose()
instance = null
}

View file

@ -68,6 +68,8 @@ import type {
HistorySearchParams,
HistorySetFavoriteParams,
HistoryAudioSource,
UnifiedSearchParams,
UnifiedSearchResponse,
DictionaryQueryParams,
DictionaryPage,
DictionaryEntry,
@ -426,6 +428,12 @@ const electronAPI = {
on(IPC_CHANNELS.HISTORY.ADDED, cb)
},
// ── Unified search (records + meetings) ───────────────
unifiedSearch: {
query: (params: UnifiedSearchParams) =>
invoke<UnifiedSearchResponse>(IPC_CHANNELS.UNIFIED_SEARCH.QUERY, params),
},
// ── Dictionary ─────────────────────────────────────────
dictionary: {
getAll: (params: DictionaryQueryParams) =>

View file

@ -19,6 +19,7 @@ import { CommandsPage } from '../pages/CommandsPage'
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
import { MeetingModePage } from '../pages/MeetingModePage'
import { UnifiedSearchPage } from '../pages/UnifiedSearchPage'
import { SettingsModal } from './SettingsModal'
import { OPEN_SETTINGS_EVENT, readOpenSettingsTab, DEFAULT_SETTINGS_TAB } from '../navigation/settingsTabs'
import type { SettingsTabId } from '../navigation/settingsTabs'
@ -132,10 +133,16 @@ function Shell(): React.ReactElement {
}
}, [t, navigate])
// 키보드: Ctrl+1..3 = 1급 화면, Ctrl+, = 설정. 입력 중에는 무시.
// 키보드: Ctrl+K = 통합 검색, Ctrl+1..3 = 1급 화면, Ctrl+, = 설정.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey || isTypingTarget(e.target)) return
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return
if (e.key.toLocaleLowerCase() === 'k') {
e.preventDefault()
if (route !== 'search') navigate('search')
return
}
if (isTypingTarget(e.target)) return
const index = ['1', '2', '3'].indexOf(e.key)
if (index >= 0) {
e.preventDefault()
@ -147,7 +154,7 @@ function Shell(): React.ReactElement {
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [navigate])
}, [navigate, route])
const openSettingsTab = useCallback(
(tab: SettingsTabId) => {
@ -186,6 +193,7 @@ function Shell(): React.ReactElement {
style={{ height: '100%', width: '100%' }}
>
{route === 'home' && <HomePage />}
{route === 'search' && <UnifiedSearchPage />}
{route === 'records' && <HistoryPage />}
{route === 'meetings' && <MeetingModePage />}
{route === 'conversation' && <VoiceConversationPage />}

View file

@ -12,8 +12,9 @@ export interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder: string
/** 포커스 단축키. '/' 만 지원. 빈 문자열이면 끈다 */
/** 포커스 단축키. '/' 또는 'Ctrl K'. 빈 문자열이면 끈다 */
shortcut?: string
autoFocus?: boolean
}
function isTypingTarget(target: EventTarget | null): boolean {
@ -21,17 +22,34 @@ function isTypingTarget(target: EventTarget | null): boolean {
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
}
export function SearchInput({ value, onChange, placeholder, shortcut = '/' }: SearchInputProps): React.ReactElement {
export function SearchInput({
value,
onChange,
placeholder,
shortcut = '/',
autoFocus = false,
}: SearchInputProps): React.ReactElement {
const { t } = useI18n()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (shortcut !== '/') return undefined
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return
const slash = shortcut === '/'
&& e.key === '/'
&& !e.ctrlKey
&& !e.metaKey
&& !e.altKey
&& !isTypingTarget(e.target)
const commandK = shortcut === 'Ctrl K'
&& (e.ctrlKey || e.metaKey)
&& !e.altKey
&& !e.shiftKey
&& e.key.toLocaleLowerCase() === 'k'
if (!slash && !commandK) return
e.preventDefault()
inputRef.current?.focus()
}
if (shortcut !== '/' && shortcut !== 'Ctrl K') return undefined
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [shortcut])
@ -39,6 +57,7 @@ export function SearchInput({ value, onChange, placeholder, shortcut = '/' }: Se
return (
<TextField
inputRef={inputRef}
autoFocus={autoFocus}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}

View file

@ -14,6 +14,7 @@ import {
Home,
Library,
MessagesSquare,
Search,
Settings,
SquareTerminal,
Users,
@ -31,6 +32,7 @@ const ICON = 18
const ROUTE_META: Record<AppRoute, { labelKey: TranslationKey; icon: React.ReactElement }> = {
home: { labelKey: 'nav.home', icon: <Home size={ICON} /> },
search: { labelKey: 'unifiedSearch.title', icon: <Search size={ICON} /> },
records: { labelKey: 'nav.records', icon: <HistoryIcon size={ICON} /> },
meetings: { labelKey: 'nav.meeting', icon: <Users size={ICON} /> },
conversation: { labelKey: 'nav.aiChat', icon: <MessagesSquare size={ICON} /> },

View file

@ -11,6 +11,7 @@ import type { SettingsTabId } from './settingsTabs'
export type AppRoute =
| 'home'
| 'search'
| 'records'
| 'meetings'
| 'conversation'

View file

@ -0,0 +1,251 @@
// 기록과 회의 제목·전사를 함께 찾고, 원본 상세로 돌려보내는 읽기 전용 검색 화면.
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Box, ButtonBase, IconButton, Skeleton, Typography } from '@mui/material'
import { AlertTriangle, ArrowLeft, FileText, Search as SearchIcon, SearchX, Users } from 'lucide-react'
import type { UnifiedSearchMatchedField, UnifiedSearchResponse, UnifiedSearchResult } from '@d3ro/core/types'
import { PhysicalButton } from '@d3ro/ui/components/ds'
import {
d3roFocusRing,
d3roPalette,
d3roRadius,
d3roTransition,
typoSx,
} from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { EmptyStateCard, PageHeader, SearchInput } from '../components/shared'
import { useAppNavigation, useRouteRequest } from '../navigation/app-navigation'
const SEARCH_DEBOUNCE_MS = 180
type SearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; response: UnifiedSearchResponse }
| { status: 'error' }
function resultIcon(source: UnifiedSearchResult['source']): React.ReactElement {
return source === 'record' ? <FileText size={18} aria-hidden /> : <Users size={18} aria-hidden />
}
export function UnifiedSearchPage(): React.ReactElement {
const { t, locale } = useI18n()
const { navigate, back } = useAppNavigation()
const [query, setQuery] = useState('')
const [state, setState] = useState<SearchState>({ status: 'idle' })
const [retryNonce, setRetryNonce] = useState(0)
const requestSequence = useRef(0)
useRouteRequest('search', (params) => {
setQuery(params.query ?? '')
})
useEffect(() => {
const normalized = query.trim()
if (!normalized) {
requestSequence.current += 1
setState({ status: 'idle' })
return undefined
}
const sequence = ++requestSequence.current
const timer = window.setTimeout(() => {
setState({ status: 'loading' })
void window.electronAPI.unifiedSearch
.query({ query: normalized })
.then((result) => {
if (requestSequence.current !== sequence) return
setState(result.success ? { status: 'ready', response: result.data } : { status: 'error' })
})
.catch(() => {
if (requestSequence.current === sequence) setState({ status: 'error' })
})
}, SEARCH_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
if (requestSequence.current === sequence) requestSequence.current += 1
}
}, [query, retryNonce])
const openResult = useCallback(
(result: UnifiedSearchResult) => {
if (result.source === 'record') navigate('records', { recordId: result.id })
else navigate('meetings', { meetingSessionId: result.id })
},
[navigate],
)
const matchLabel = useCallback(
(field: UnifiedSearchMatchedField): string => {
switch (field) {
case 'title':
return t('unifiedSearch.matchedTitle')
case 'content':
return t('unifiedSearch.matchedContent')
case 'transcript':
return t('unifiedSearch.matchedTranscript')
}
},
[t],
)
const resultCount = state.status === 'ready'
? t('unifiedSearch.resultCount', { count: state.response.total })
: undefined
let body: React.ReactNode
if (state.status === 'idle') {
body = (
<EmptyStateCard
icon={<SearchIcon />}
message={t('unifiedSearch.promptTitle')}
description={t('unifiedSearch.promptDescription')}
/>
)
} else if (state.status === 'loading') {
body = (
<Box aria-busy="true" aria-label={t('unifiedSearch.loading')} sx={{ pt: 1 }}>
{Array.from({ length: 5 }, (_, index) => (
<Box key={index} sx={{ display: 'flex', gap: 2, px: 1.5, py: 1.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
<Skeleton variant="circular" width={18} height={18} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width={`${52 + index * 7}%`} />
<Skeleton variant="text" width={`${78 - index * 5}%`} />
</Box>
</Box>
))}
</Box>
)
} else if (state.status === 'error') {
body = (
<EmptyStateCard
icon={<AlertTriangle />}
message={t('unifiedSearch.loadErrorTitle')}
description={t('unifiedSearch.loadErrorDescription')}
action={
<PhysicalButton tone="glass" size="small" onClick={() => setRetryNonce((value) => value + 1)}>
{t('unifiedSearch.retry')}
</PhysicalButton>
}
/>
)
} else if (state.response.results.length === 0) {
body = (
<EmptyStateCard
icon={<SearchX />}
message={t('unifiedSearch.noResultsTitle')}
description={t('unifiedSearch.noResultsDescription', { query: state.response.query })}
/>
)
} else {
body = (
<Box component="ul" aria-label={t('unifiedSearch.resultsLabel')} sx={{ m: 0, p: 0, listStyle: 'none' }}>
{state.response.results.map((result) => {
const sourceLabel = result.source === 'record'
? t('unifiedSearch.sourceRecord')
: t('unifiedSearch.sourceMeeting')
const title = result.title?.trim() || (result.source === 'record'
? t('unifiedSearch.untitledRecord')
: t('unifiedSearch.untitledMeeting'))
return (
<Box component="li" key={`${result.source}:${result.id}`}>
<ButtonBase
onClick={() => openResult(result)}
aria-label={t('unifiedSearch.openResult', { source: sourceLabel, title })}
sx={{
width: '100%',
minHeight: 72,
display: 'grid',
gridTemplateColumns: '24px minmax(0, 1fr) auto',
alignItems: 'start',
gap: 1.5,
px: 1.5,
py: 1.5,
textAlign: 'left',
color: d3roPalette.text.secondary,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.button,
transition: d3roTransition('background-color', 'color'),
'&:hover': { bgcolor: d3roPalette.surface.overlaySoft },
'&:focus-visible': d3roFocusRing,
}}
>
<Box aria-hidden sx={{ color: d3roPalette.text.tertiary, pt: 0.25 }}>
{resultIcon(result.source)}
</Box>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
<Typography component="h2" sx={{ ...typoSx('compact'), fontWeight: 500, color: d3roPalette.text.primary }}>
{title}
</Typography>
<Typography component="span" sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary }}>
{sourceLabel} · {matchLabel(result.matchedField)}
</Typography>
</Box>
<Typography
sx={{
...typoSx('compact'),
color: d3roPalette.text.secondary,
mt: 0.5,
overflowWrap: 'anywhere',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{result.excerpt || t('unifiedSearch.noPreview')}
</Typography>
</Box>
<Typography
component="time"
dateTime={new Date(result.occurredAt).toISOString()}
sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary, whiteSpace: 'nowrap', pt: 0.25 }}
>
{new Date(result.occurredAt).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' })}
</Typography>
</ButtonBase>
</Box>
)
})}
</Box>
)
}
return (
<Box
onKeyDown={(event) => {
if (event.key === 'Escape' && query.trim().length === 0) back()
}}
sx={{ maxWidth: 1000, mx: 'auto', px: 5, py: 4, pb: 10 }}
>
<PageHeader
title={t('unifiedSearch.title')}
count={resultCount}
description={t('unifiedSearch.description')}
action={
<IconButton aria-label={t('unifiedSearch.back')} onClick={back} sx={{ color: d3roPalette.text.secondary }}>
<ArrowLeft size={18} aria-hidden />
</IconButton>
}
/>
<SearchInput
value={query}
onChange={setQuery}
placeholder={t('unifiedSearch.placeholder')}
shortcut="Ctrl K"
autoFocus
/>
<Box role="status" aria-live="polite" sx={{ minHeight: 18, mb: 1 }}>
{resultCount && (
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary }}>
{resultCount}
</Typography>
)}
</Box>
{body}
</Box>
)
}

View file

@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
const handlers = vi.hoisted(
() => new Map<string, (event: unknown, params: unknown) => unknown>()
)
const search = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, params: unknown) => unknown) => {
handlers.set(channel, handler)
}),
},
}))
vi.mock('../../../src/main/services/UnifiedSearchService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/main/services/UnifiedSearchService')>()
return {
...actual,
getUnifiedSearchService: () => ({ search }),
}
})
beforeEach(async () => {
vi.clearAllMocks()
handlers.clear()
const mod = await import('../../../src/main/ipc/unified-search-handlers')
mod.registerUnifiedSearchHandlers()
})
describe('UNIFIED_SEARCH.QUERY', () => {
it.each([
null,
{},
{ query: 42 },
{ query: '계획', limit: Number.NaN },
{ query: '계획', limit: Number.NEGATIVE_INFINITY },
])('returns ConfigInvalidValue for malformed renderer input: %o', async (params) => {
const handler = handlers.get(IPC_CHANNELS.UNIFIED_SEARCH.QUERY)
if (!handler) throw new Error('unified search handler was not registered')
const result = await handler({}, params)
expect(result).toMatchObject({
success: false,
error: { code: ErrorCode.ConfigInvalidValue },
})
expect(search).not.toHaveBeenCalled()
})
it('passes validated input to the service and returns IPC success', async () => {
const handler = handlers.get(IPC_CHANNELS.UNIFIED_SEARCH.QUERY)
if (!handler) throw new Error('unified search handler was not registered')
search.mockReturnValue({ query: '계획', results: [], total: 0 })
const result = await handler({}, { query: '계획', limit: 10 })
expect(search).toHaveBeenCalledWith({ query: '계획', limit: 10 })
expect(result).toEqual({
success: true,
data: { query: '계획', results: [], total: 0 },
})
})
})

View file

@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import type { History, MeetingSessionRow } from '../../../src/main/db/schema'
interface MockBuilderOptions {
get?: unknown
all?: unknown[]
error?: Error
}
function createMockBuilder(options: MockBuilderOptions = {}) {
const builder = {
from: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
get: vi.fn(),
all: vi.fn(),
}
builder.from.mockImplementation(() => {
if (options.error !== undefined) throw options.error
return builder
})
builder.where.mockReturnValue(builder)
builder.orderBy.mockReturnValue(builder)
builder.limit.mockReturnValue(builder)
builder.get.mockReturnValue(options.get ?? null)
builder.all.mockReturnValue(options.all ?? [])
return builder
}
const selectQueue: ReturnType<typeof createMockBuilder>[] = []
const mockDb = {
select: vi.fn(() => selectQueue.shift() ?? createMockBuilder()),
}
vi.mock('../../../src/main/db', () => ({
getDatabase: () => mockDb,
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
const record: History = {
id: 'record-1',
title: '제품 메모',
originalText: '초기 원문',
polishedText: '다음 분기 출시 계획을 정리했습니다.',
focusedApp: null,
focusedAppName: null,
focusedAppWindowTitle: null,
mode: 'dictation',
status: 'completed',
errorCode: null,
audioLocalPath: null,
duration: 10,
detectedLanguage: 'ko',
micDevice: null,
wordCount: 6,
sttModel: null,
llmModel: null,
sttLatencyMs: null,
llmLatencyMs: null,
createdAt: 100,
updatedAt: 100,
appVersion: '1.9.0',
summaryText: null,
isFavorite: false,
}
const meeting: MeetingSessionRow = {
id: 'meeting-1',
title: '주간 회의',
status: 'completed',
startedAt: 200,
endedAt: 300,
durationMs: 100,
rawTranscript: '출시 계획을 다음 주에 검토합니다.',
editedTranscript: null,
minutesMarkdown: null,
minutesJson: null,
sttModel: null,
llmModel: null,
sttLatencyMs: null,
llmLatencyMs: null,
errorMessage: null,
createdAt: 200,
updatedAt: 300,
}
describe('UnifiedSearchService', () => {
beforeEach(() => {
selectQueue.length = 0
vi.clearAllMocks()
})
it('normalizes input and creates a bounded excerpt around the match', async () => {
const { createUnifiedSearchExcerpt, normalizeUnifiedSearchQuery } = await import(
'../../../src/main/services/UnifiedSearchService'
)
expect(normalizeUnifiedSearchQuery(' 출시\n 계획 ')).toBe('출시 계획')
expect(normalizeUnifiedSearchQuery('a'.repeat(250))).toHaveLength(200)
expect(normalizeUnifiedSearchQuery(` ${'a'.repeat(199)} b`)).toBe('a'.repeat(199))
const excerpt = createUnifiedSearchExcerpt(`앞부분 ${'x'.repeat(80)} 출시 계획 ${'y'.repeat(80)}`, '출시 계획', 60)
expect(excerpt).toContain('출시 계획')
expect(excerpt.startsWith('…')).toBe(true)
expect(excerpt.endsWith('…')).toBe(true)
})
it('merges records and meeting transcripts by occurrence time and preserves source ids', async () => {
selectQueue.push(
createMockBuilder({ get: { count: 1 } }),
createMockBuilder({ all: [record] }),
createMockBuilder({ get: { count: 1 } }),
createMockBuilder({ all: [meeting] }),
)
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
const response = getUnifiedSearchService().search({ query: '계획', limit: 10 })
expect(response.total).toBe(2)
expect(response.results.map(({ source, id }) => ({ source, id }))).toEqual([
{ source: 'meeting', id: 'meeting-1' },
{ source: 'record', id: 'record-1' },
])
expect(response.results[0]).toMatchObject({
matchedField: 'transcript',
excerpt: '출시 계획을 다음 주에 검토합니다.',
})
expect(response.results[1]).toMatchObject({ matchedField: 'content' })
})
it('returns immediately for an empty query', async () => {
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
expect(getUnifiedSearchService().search({ query: ' ' })).toEqual({
query: '',
results: [],
total: 0,
})
expect(mockDb.select).not.toHaveBeenCalled()
})
it.each([
null,
undefined,
{},
{ query: 42 },
{ query: '계획', limit: Number.NaN },
{ query: '계획', limit: Number.POSITIVE_INFINITY },
{ query: '계획', limit: '10' },
])('rejects malformed runtime parameters with ConfigInvalidValue: %o', async (params) => {
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
expect(() => getUnifiedSearchService().search(params)).toThrowError(
expect.objectContaining({ code: ErrorCode.ConfigInvalidValue }),
)
expect(mockDb.select).not.toHaveBeenCalled()
})
it('maps database failures to the IPC error contract', async () => {
selectQueue.push(createMockBuilder({ error: new Error('database unavailable') }))
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
expect(() => getUnifiedSearchService().search({ query: '계획' })).toThrowError(
expect.objectContaining({ code: ErrorCode.DBQueryFailed }),
)
})
})

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>

View file

@ -322,3 +322,32 @@
- **자막 받침 테마화 안 함** — 위 예외.
- **첫 실행 온보딩 Esc 닫기 안 함** — 기존 onboarding-dismiss 정책·테스트 존중, 대신 "나중에 설정" 버튼.
- **다른 8개 로케일 번역 안 함** — ko·en 만 새 문구. 나머지는 en 폴백. 한 번에 번역 맞출 것.
### 2026-09-28 추기 · 물결 ④ 모바일 셸·네이티브 DS
- 하단은 이동 전용 **홈 · 기록 · 회의 · 더보기** 4탭이다. `Record`는 녹음 상태를 보존하는 숨은 탭 경로이고,
떠 있는 녹음 버튼은 홈·기록·회의에서만 보인다. 메뉴의 받아쓰기·회의 녹음·파일 가져오기는 각각 `Record`,
`Meetings(createRequest)`, `Record(importRequest)`로 한 번씩 전달된다.
- 홈은 오늘 요약과 최근 기록·회의, 통합 검색 진입을 한 화면에 둔다. 더보기는 AI 대화·명령어·사전·지식·템플릿·
태그·팀·음성 액션·알림·데이터, 계정·요금제·기기·설정을 묶고 Admin은 역할이 있을 때만 보인다.
- `packages/ui-native`는 데스크톱 v4 원시 팔레트와 같은 dark/light 의미 토큰을 쓴다. 한글 본문은 시스템 산세리프,
숫자만 tabular numeral을 쓰며, 공용 컨트롤 최소 높이는 48dp다. 삭제된 장식용 `Header`·`AppStatusBar` 대신
새 `UnifiedSearch`와 이번 v4에서 전환한 화면은 앱의 `ScreenHeader`로 뒤로가기를 제공하고 탭 루트에는 뒤로가기를
두지 않는다. 기존 stack 화면 전부가 전환됐다는 뜻은 아니다.
- 중복 진입점이던 `DashScreen`과 `WorksHubScreen`은 삭제했다. `WorksHub`의 화면 내부 도구 검색·고정은 내비 상태를
이중 관리하던 복잡도를 줄이기 위해 의도적으로 종료했다. 목적지는 1급 탭과 더보기에서 그대로 열리며 사용자 기록·회의·
도구 데이터는 삭제하지 않는다. 과거 `works-hub-pins.v1` AsyncStorage 키는 비활성 상태로 남을 수 있다.
- 전체 모바일 Jest는 86 suites / 589 tests가 통과했다. 같은 로컬 E2E APK에서 320dp dark는
`scratch/design-audit/wave45/navigation-shell-search-320-dark-verified.xml`(1/1, 88초), 411dp light는
`scratch/design-audit/wave45/navigation-shell-search-411-light-verified.xml`(1/1, 100초)로 통과했다. 셸·두 viewport/theme
조합의 완료 증거이며 실제 TalkBack·font scale·rotation 증거는 아니다. 추적 가능한 요약은
`docs/evidence/wave45-mobile-shell-search-2026-09-28.md`에 둔다.
### 2026-09-28 추기 · 물결 ⑤ 기록+회의 통합 검색
- 검색 계약은 `@d3ro/core/types`의 `UnifiedSearch*` 타입이다. 결과는 원본을 합치지 않고 `source + id`로 기록·회의
상세에 연결하며, 검색 범위는 기록 제목/원문/교정문/요약과 회의 제목/원문 전사/편집 전사다.
- 데스크톱은 `Ctrl+K`와 사이드바 검색에서 SQLite `UnifiedSearchService`를 호출하고, 모바일은 홈에서 Supabase의
사용자 범위 기록·회의를 병렬 조회해 최신순으로 합친다. 빈 검색은 조회하지 않고 오류·재시도·빈 결과를 구분한다.
- 모바일 320dp dark와 411dp light 경로는 기록·회의 결과와 두 상세 화면 이동까지 위 canonical 보고서에서 통과했다.
통합 검색 자체는 아직 부분 완료다. 데스크톱은 소스와 자동 테스트까지만 확인했고 Electron에서 `Ctrl+K`·결과 이동을
수동으로 실행한 증거가 없다. 별도 `transcripts` 구간 검색도 없고, 모바일은 검색 때 원문 필드를 과전송하며 실행된 요청을
취소하지 않는다. 어느 표면도 FTS/index 또는 실제 대규모 데이터 성능을 증명하지 않았다.

View file

@ -0,0 +1,52 @@
# Wave 4/5 모바일 셸·통합 검색 검증 요약 — 2026-09-28
이 문서는 `.gitignore` 대상인 `scratch/design-audit/wave45/`의 로컬 원본을 대신해 커밋 가능한 메타데이터와 판정 경계를 남긴다. 비밀값이 들어갈 수 있는 Maestro debug bundle과 로그는 복사하지 않았고, 아래에는 테스트 자격 증명이 없다.
## 실행 대상
| 항목 | 값 |
|---|---|
| package | `com.d3ro.voice` |
| versionName | `1.9.0-e2e.20260928.2` |
| versionCode | `202609282` |
| APK | `apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk` |
| 크기 | 147063583 bytes |
| SHA-256 | `96EBA2A47E54033EB0DEFCE4E4D282F1D06F162A5F7DF9457E99609D98745D58` |
이 APK는 로컬 Wave 4/5 E2E artifact다. production 서명 APK/AAB, Play track, Play 설치 또는 production release provenance 증거가 아니다.
## 자동·런타임 결과
| 게이트 | 결과 | 원본 |
|---|---|---|
| 모바일 전체 Jest | **86 suites / 589 tests PASS** | `npm --prefix apps/mobile-rn test` |
| 320dp dark journey | **1/1 PASS, 0 failure, 88.0 s** | `scratch/design-audit/wave45/navigation-shell-search-320-dark-verified.xml` |
| 411dp light journey | **1/1 PASS, 0 failure, 100.0 s** | `scratch/design-audit/wave45/navigation-shell-search-411-light-verified.xml` |
원본 XML 무결성:
- 320dp dark: 440 bytes, SHA-256 `55AA0A3FDAF07B9FD4CB4F67341C798A3A952D7C6E76B829D382AC5390CCEA38`
- 411dp light: 442 bytes, SHA-256 `017B882E24150F66362ACF367A5F4DB9EB98E56ED743359B01BDDE68DCF3337D`
두 canonical run은 visible Home/Records/Meetings/More 4탭, FAB 열기와 Android Back 닫기, Home 통합 검색의 record/meeting 결과와 각 상세 이동, Records/Meetings/More 도착을 검사했다. 파일명이 run의 viewport/theme를 보존하지만 XML 본문에는 viewport/theme, APK digest/version, Android/API 또는 Maestro version이 내장되지 않으므로 APK identity와 XML 사이를 암호학적으로 묶은 provenance로 읽으면 안 된다. 이전 `*-final.xml` 실패본은 canonical이 아니다.
## 상태 판정
- `GAP-UX-04`, 모바일 `SHELL-13`, 모바일 `SHELL-14`: `[x]`. 셸 계약과 두 대표 viewport/theme journey가 통과했다.
- `Q-021`, 모바일 `SHELL-15`: `[~]`. 실제 TalkBack/focus order, font scale, rotation 증거는 없다. 48dp와 theme contrast는 source/Jest 증거다.
- `GAP-UX-05`, `MEM-15`, `H-016`: `[~]`. 모바일 결과→상세 journey가 두 run에서 통과했어도 검색 구현·성능 계약은 아직 닫히지 않았다.
`ScreenHeader`는 새 `UnifiedSearch`와 이번 v4에서 전환한 화면에 적용했다. 모든 역사적 root-stack 화면이 공용 헤더로 전환됐다는 주장은 하지 않는다. 런타임 journey는 세 FAB action 각각의 실제 route, hidden `Record` route의 탭 바 비노출, 48dp geometry도 직접 assertion하지 않으며 이 계약은 Jest와 소스 증거에 의존한다. Maestro 로그의 부수 heartbeat 기록 오류 때문에 이 run을 Q-023의 “로그 오류 0” 증거로도 사용하지 않는다.
## 의도적 제거
`DashScreen`과 `WorksHubScreen`은 v4 셸에서 제거했다. Home이 dashboard 역할을 맡고, WorksHub의 목적지는 1급 탭과 More에서 계속 접근한다. WorksHub 전용 로컬 도구 검색·고정은 중복 navigation taxonomy와 local ordering state를 줄이기 위해 의도적으로 종료했다. 사용자 기록·회의·도구 콘텐츠를 삭제한 작업은 아니며, 업그레이드 설치에는 사용하지 않는 `works-hub-pins.v1` AsyncStorage 키가 남을 수 있다.
## 남은 게이트
- 데스크톱 Electron에서 `Ctrl+K`와 record/meeting 결과 이동 수동 검증
- 별도 `transcripts` segment 검색 설계·구현
- 모바일 raw-text overfetch 제거와 in-flight request cancellation
- 양 표면 FTS/index 및 실제 대규모 데이터 성능 기준·측정
- TalkBack/focus order, font scale, rotation 실기기 검증
- production Firebase/CI secret, signed AAB, Play track, Fold6 Play-installed artifact 검증

View file

@ -13,9 +13,9 @@ The canonical place for types and cross-surface logic. Both desktop and web/mobi
| Area | Exports | Notes |
|---|---|---|
| Types | `./types` | Domain types shared across surfaces |
| Types | `./types` | Domain types shared across surfaces, including the record+meeting `UnifiedSearchSource` / `UnifiedSearchMatchedField` / `UnifiedSearchResult` / `UnifiedSearchParams` / `UnifiedSearchResponse` contract |
| Errors | `./errors` | `D3ROError`, `ErrorCode` |
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, INPUT_TELEMETRY, SUGGESTION, POPUP_SUGGESTION) |
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, UNIFIED_SEARCH, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, INPUT_TELEMETRY, SUGGESTION, POPUP_SUGGESTION) |
| Key bindings | `./keybinding` | **SSOT** for every global shortcut in the app: `KeyBinding` (`device`/`code`/`ctrl`/`alt`/`shift`/`meta`), `KEY_CATALOG` (10 selectable groups incl. mouse), `KEYBINDING_ACTIONS` (9 rebindable actions incl. the three suggestion actions), `bindingKey`/`normalizeBinding`/`validateBinding`/`detectBindingConflicts`/`formatBindingSegments`/`searchKeyCatalog`/`parseBindingMap`. Persisted as `AppConfig.keyBindings`. i18n keys are exposed as plain `string` so core stays independent of `@d3ro/i18n`; consumers narrow at the boundary (`asTranslationKey`) and a contract test guards the keys — accepted constraint, `11` §7 CONSTRAINT-I18N-01. Tests: `__tests__/keybinding*.test.ts` via `vitest.config.ts` (`npm run test --workspace=@d3ro/core`), 117 cases as of 2026-09-21 |
| Input intelligence | `./input-intelligence` | **SSOT** for the input-telemetry and next-sentence-suggestion domain (added 2026-09-21): key classification (`classifyKeyStroke`), text metrics (`countWords`/`countSentences`/`endsSentence`/`textBeforeCaret`), `computeTypedDelta` (UIA snapshot diff — the IME-safe way to count typed text), `decideSuggestion` + `isAppExcluded` (when to request / skip / clear), `parseSuggestionCandidates`/`sanitizeSuggestionLine` (prompt-leak and prefix-echo defence), `anchorFloatingPanel` (caret-anchored overlay placement), `mergeActivityBucket`/`summarizeActivity`/`pixelsToMeters`, `extractPhrases`/`selectPhraseHints`, `INPUT_TELEMETRY_DEFAULTS`/`SUGGESTION_DEFAULTS` |
| Constants | `./constants` | Shared constants |
@ -48,10 +48,11 @@ Peers: React 19, MUI 7, Emotion 11. Depends on `@d3ro/core`.
| Provides |
|---|
| `NativeThemeProvider` / `useNativePalette`, native palette/typo/radius/fonts |
| Components: `MetalCard`, `PhosphorText`, `Led`, `PhysicalButton`, `ScreenPanel`, `WaveBars`, `AppStatusBar`, `Header`, `FilterChip` |
| `NativeThemeProvider` / `useNativePalette`; dark/light/get-palette, raw/status, typo/radius/fonts/space/control/weight/motion/numeral tokens. The v4 semantic colors mirror `packages/ui/src/theme-raw.ts`; `d3roNativeControl.h` is the 48dp control floor |
| Components: `MetalCard`, `PhosphorText`, `Led`, `PhysicalButton`, `ScreenPanel`, `WaveBars`, `FilterChip` |
Peers: `react`, `react-native`. Consumed by `apps/mobile-rn` via file: workspace dependencies.
App-specific `ScreenHeader`, `RecordFab`, and `UnifiedSearchResultRow` stay under `apps/mobile-rn/src/components`; they are not shared package exports.
---

View file

@ -54,6 +54,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
| Service | Purpose |
|---|---|
| `HistoryService` | SQLite history CRUD/search/stats |
| `UnifiedSearchService` | Read-only SQLite search across history title/content and meeting title/transcript; merges newest-first results without changing either schema |
| `DictionaryService` | Custom vocabulary CRUD/search + cloud sync hooks + JSON/CSV import/export (`dictionary:import`/`export`, save/open dialogs) |
| `MemoService` | Memo tags over history (`memo_tags`) |
| `RAGService` | Local RAG: `nomic-embed-text` embeddings, cosine search over `rag_chunks` |
@ -131,6 +132,7 @@ Registry: `src/main/ipc/index.ts` calls 31 `registerXHandlers()` in fixed order.
| `dictionary-handlers` | DICTIONARY |
| `file-transcription-handlers` | FILE_TRANSCRIPTION |
| `history-handlers` | HISTORY (incl. `history:setFavorite`, `history:getAudio` → local bytes or signed URL) + `stats:getSummary` |
| `unified-search-handlers` | UNIFIED_SEARCH (`unifiedSearch:query`), returning `IPCResult<UnifiedSearchResponse>` |
| `input-telemetry-handlers` | INPUT_TELEMETRY |
| `instruction-handlers` | INSTRUCTION |
| `keybinding-handlers` | KEYBINDING |
@ -156,7 +158,7 @@ The **`KEYBINDING`** group replaced the old per-action `HOTKEY` group. `HOTKEY`
**`LLM.PROCESS` normalizes at the IPC boundary.** The handler runs `buildInstructionInvocation` itself when `action === 'custom'` with a `customPrompt` (`llm-handlers.ts:94-108`), so the renderer passes the **raw instruction text** and never duplicates the substitution or argument-placement rules. This is what makes `VoiceModeService`, `ChainService`, and `LLM.PROCESS` literally share one implementation. No channel or type changed for this; `LLMProcessParams` is unchanged.
Preload exposes **`window.electronAPI`** with 35 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, inputTelemetry, suggestion`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
Preload exposes **`window.electronAPI`** with 37 namespaces: `platform, audio, config, voice, stt, runtime, keybinding, llm (incl. premium), history, unifiedSearch, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, inputTelemetry, suggestion`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
---
@ -185,17 +187,18 @@ Vanilla popups (`src/renderer/popups/`):
## 5. Renderer IA
Routing is state-based in `AppLayout.tsx` (`Route` union + `NAV_ITEMS`), no react-router.
Routing is state-based in `renderer/navigation/app-navigation.tsx` (`AppRoute`, params, history/back), no react-router. `Ctrl+K` opens the search route and `Ctrl+1..3` opens Home/Records/Meetings.
| Page | Route | Feature |
|---|---|---|
| `DashboardPage` | dashboard | Voice cockpit: hero, bento tiles, multi-engine hub (STT/LLM), telemetry, recent history, file drop |
| `HistoryPage` | history | History & memory timeline; search, tag filter, pagination, export/delete |
| `HomePage` | home | Today summary, recording/meeting/caption starts, current blockers, recent records/meetings, file transcription, bounded free usage |
| `UnifiedSearchPage` | search | Record+meeting search, newest-first merged results, source-aware detail navigation; source/tests complete, Electron manual proof pending |
| `HistoryPage` | records | History & memory timeline; search, tag/mode/favorite filters, pagination, export/delete |
| `DictionaryPage` | dictionary | Custom vocabulary editor |
| `CommandsPage` | commands | Custom instructions + voice keyword rules + LLM chains + dictation templates |
| `VoiceConversationPage` | conversation | Duplex voice assistant (local pipeline vs OpenAI Realtime) |
| `KnowledgeBasePage` | knowledge | Local RAG: add/index docs, semantic query, reindex/remove |
| `MeetingModePage` | meeting | Meeting studio: live transcript, memos, doc generation/export, diarization |
| `MeetingModePage` | meetings | Meeting studio: live transcript, memos, doc generation/export, diarization |
Modals/components: `SettingsModal` (tabs General/Audio/STT/LLM/Input/License/Cloud/About), `LicenseModal`, `LicenseTab`, `CloudSyncSection`, `InputInsightsPanel` (consent + suggestion policy + weekly insights + learned phrases), `InputConsentPanel` (receipt + current-app exclusion recommendation), `InputInsightsView` (flow, friction and app-quality summaries), `OnboardingModal`, `UpgradePromptModal`, `ProBadge`, `TemplateSection`, `FileDropZone`, `OllamaGuideModal`, `CodexOAuthGuideModal`, `TitleBar`, `StatusBar`, meeting components (9), voice-conversation, payment (`CheckoutModal`, `checkout-flow.ts`), support (`SupportModal`), ads (`AdBanner`, `RewardedQuotaModal`), shared cards.
@ -220,6 +223,7 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s
- **Local STT is packaged** (`1.3.0`): `electron-builder.yml` `extraResources` copies `sidecar-dist/sidecar` → `resources/sidecar` and `resources/ffmpeg` → `resources/ffmpeg`; `scripts/ci/verify-sidecar-bundle.mjs` gates packaging. Build locally with `npm --prefix apps/desktop run sidecar:setup && npm --prefix apps/desktop run sidecar:build`. The sidecar stays in console mode so `stdout`/`stderr` reach the app log (UTF-8, line-buffered); a packaged sidecar **must** exist or startup fails loudly instead of silently falling back to a system Python.
- All local engine URLs (`LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager`) pass through `src/main/utils/loopback.ts`, which rewrites `localhost` to `127.0.0.1`, because some Windows hosts resolve `localhost` to IPv6 only and local engines bind IPv4.
- Meeting intelligence, RAG, voice conversation (local + Realtime), captions, file transcription: implemented.
- **Unified search (2026-09-28): source and automated tests only.** `UnifiedSearchService` + `unifiedSearch:query` IPC + preload bridge + `UnifiedSearchPage` search records and meetings and route to their detail views. The focused service and IPC tests pass; the renderer is source/type-gated, not headful-tested. No external-terminal Electron run has yet proven `Ctrl+K`, result rendering, or both detail transitions; treat the desktop capability as `[~]` until that run is retained.
- **LLM instruction prompts: fixed 2026-09-21 (`9c2b4d4`), not yet verified in a running app.** Running a custom instruction inserted the instruction's own wording instead of the processed result. Two faults stacked: the instruction was passed in the `text` argument with the system-prompt argument left empty, and `BASE_SYSTEM_PROMPTS` has no `custom` key so resolution fell back to `refine` **silently** — the model polished the instruction and the transcript never reached it; separately, only `{{text}}` was substituted and none of the five built-in presets use it (`{{targetLanguage}}`, `{{userPrompt}}`, or no placeholder), so the substitution was a no-op from the day it was written. Introduced in `fea923d` (2026-04-05) and present in every release `v0.1.0-alpha`..`v1.4.0` — **the path never worked; this is not a regression.** Plain actions (`refine`/`summarize`/`grammar`/`expand`) were unaffected and are now pinned by regression cases. The fix routes all three entry paths through `llm-prompts.ts` (see §2) and additionally corrects two things found alongside it: a voice shortcut naming an instruction was nullified by the `defaultLLMAction === 'none'` gate (`VoiceModeService.ts:779`), and the commands-page pipeline bench called `llm.generate`, which preload does not expose, so every run threw and the `catch` displayed the **input** as if it had succeeded — a fail-closed violation that is the reason the bug went unnoticed for five months (`CommandsPage.tsx:180-205`, now on `llm.process` with failures rendered as failures).
- **Verification limits — do not read this as verified.** Unit tests pass (`llm-prompts.test.ts` 21, `llm-handlers.test.ts` 7, `VoiceModeService.test.ts` 22, `ChainService.test.ts` 5), and each of the four fixes was reverted individually to confirm the tests actually fail without it. `npm run lint` (apps/desktop scope) passes; `tsconfig.check.json` errors went 36 → 35 (the `llm.generate` error is gone) with no errors in the touched files. But there is **no running-app run**, and `tests/red/{instruction,chain,voice,config}.usecase.test.ts` — precisely the related paths — never executed because of the `better-sqlite3` ABI mismatch. That range is neither passing nor failing; it is untested (`11` GAP-LLM-02, GAP-INFRA-06).
- **Key bindings: implemented and verified on Windows.** Every global shortcut now comes from one contract (`@d3ro/core/keybinding`) with multiple bindings per action, mouse-button support, and no hardcoded accelerators left in `bootstrap.ts`. A manual run on 2026-09-21 confirmed legacy migration (custom values preserved), 6 actions loaded, the uiohook keyboard **and** mouse hook active with zero boot errors, and multi-binding working; contract side is `packages/core` 117 tests GREEN with no type errors in the key-binding files (`11` GAP-KEY-01 `[x]`). Two things remain open: `KeyBindingService` has no unit test of its own, and macOS/Linux mouse behavior is unconfirmed (`11` GAP-KEY-02). The rewrite also fixed a dead hands-free double-press path, an order-dependent reserved-combo check, a `globalShortcut.unregisterAll()` that wiped the popup accelerators, and a `setEnabled(true)` that re-enabled hooking with an empty binding set.
@ -252,6 +256,7 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s
| LLM prompt / placeholder SSOT | `src/main/services/llm-prompts.ts` (shared by `VoiceModeService`, `ChainService`, `ipc/llm-handlers.ts`) |
| DB schema | `src/main/db/schema.ts` |
| Renderer shell / routes | `src/renderer/components/AppLayout.tsx` |
| Unified search | `src/main/services/UnifiedSearchService.ts`, `src/main/ipc/unified-search-handlers.ts`, `src/renderer/pages/UnifiedSearchPage.tsx` |
| Update feed SSOT | `src/main/update-feed.ts` |
| Update policy SSOT | `release/update-policy.json` + `src/main/update-policy.ts` |
| Path/loopback resolution | `src/main/utils/paths.ts`, `src/main/utils/loopback.ts` |

View file

@ -13,23 +13,25 @@ Root stack (`App.tsx` `RootNavigator`) branches on auth/onboarding:
- `recovery` → `UpdatePassword`
- `onboarding` → Onboarding + InviteAccept + Login
- unauthenticated → Login, SignUp, ForgotPassword, InviteAccept
- authenticated → `Main` (`TabNavigator`) + ~22 stack screens
- authenticated → `Main` (`TabNavigator`) + 23 stack screens
Bottom tabs (`TabNavigator.tsx`): **Dash / Works / Record (center raised FAB) / Talk / Settings**.
Bottom tabs (`TabNavigator.tsx`): **Home / Records / Meetings / More**. `Record` remains a hidden tab route so an active recording stays mounted; it never occupies a visible tab slot. A floating `RecordFab` appears on Home/Records/Meetings (not More/Record) and routes **dictation → Record**, **meeting recording → Meetings + `createRequest`**, **audio import → Record + `importRequest`**. Android back, scrim press, and action selection close its menu; tab back behavior is history-based.
Home opens `UnifiedSearch` on the root stack. New and converted v4 flows use the app-level `ScreenHeader` with a 48dp back action; tab roots such as Records and Meetings show a title without a back action. Historical stack screens that were not part of this conversion may still use their own header.
Deep links: `d3ro-voice://` and `https://d3ro.chanpaca.net` (`accept-invite`).
Provider stack: `GestureHandlerRootView → SafeAreaProvider → AuthProvider → EntitlementProvider → BillingProvider → MobileAdsProvider → MobilePreferencesProvider → DeviceProvider → LocalizedApplication (I18n + StatusBar)`.
---
## 2. Screens (`src/screens/`, 30)
## 2. Screens (`src/screens/`: 31 active source files)
| Screen | Feature |
|---|---|
| `RecordScreen` | Recording + local-whisper/cloud transcription, import audio, share |
| `DashScreen` | Dashboard: usage stats, tier, streaks, trends |
| `WorksHubScreen` | WORKS hub: pinned feature drill-down (desktop-parity) |
| `TalkScreen` | Voice/text AI chat: STT, LLM streaming, TTS |
| `HomeScreen` | Today summary, recent 5 records / 3 meetings, unified-search entry, Free plan entry |
| `MoreScreen` | Tools and account routes; Admin entry is role-gated |
| `UnifiedSearchScreen` | Authenticated record+meeting search, newest-first merged results, source-aware detail navigation |
| `RecordScreen` | Recording + local-whisper/cloud transcription, import audio, share; entered from the hidden Record route/FAB |
| `TalkScreen` | Voice/text AI chat: STT, LLM streaming, TTS; opened from More on the root stack |
| `HistoryScreen` / `HistoryDetailScreen` | History list/detail: sync, search, filters, audio playback, edit/share/favorite/delete |
| `MeetingsScreen` / `MeetingDetailScreen` | Meeting workspaces: create/rename/delete, transcript, memos, documents, export, content report |
| `MemosScreen` | Memos: list, tags, search, share |
@ -46,16 +48,17 @@ Provider stack: `GestureHandlerRootView → SafeAreaProvider → AuthProvider
| `AdminScreen` / `AdminUserDetailScreen` | Role-gated admin: users, subscriptions, audit, role/tier edits |
| `ProPaywallScreen` | Pro/Pro+ paywall: Play IAP, restore, rewarded ads |
| `OnboardingScreen` | First-run audience/theme/locale |
| `SettingsScreen` | Preferences, locale/theme, privacy links, logout |
| `SettingsScreen` | Preferences, locale/theme, privacy links, logout; opened from More on the root stack |
| `LoginScreen` / `SignUpScreen` / `ForgotPasswordScreen` / `UpdatePasswordScreen` / `InviteAcceptScreen` | Auth flows |
---
## 3. Features (`src/features/`, 20 modules)
## 3. Features (`src/features/`, 23 modules)
| Module | Purpose |
|---|---|
| `recording/` | Durable offline recording queue, retry processing, language handling |
| `audio/` | Linked recording/audio cleanup |
| `import/` | Audio pick/decode/validate, local Whisper fallback, resumable multipart upload, Android share-intent intake |
| `history/` | History service + local cache + sync hook |
| `meetings/` | Meeting workspace CRUD, realtime channels, processing jobs, transcripts/memos/docs |
@ -70,11 +73,13 @@ Provider stack: `GestureHandlerRootView → SafeAreaProvider → AuthProvider
| `dashboard/` | Dashboard stats + recent entries |
| `templates/` | Templates + generation idempotency |
| `auth/` | OAuth sign-in (in-app browser, PKCE) + identity linking |
| `account/` | Account deletion service and server-confirmed exit boundary |
| `admin/` | Admin user/subscription/audit APIs + session/role |
| `data-portability/` | Canonical JSON export/import, schema validation, legacy import, meeting export, file sharing |
| `chat/` | Chat LLM normalization/streaming, reportable generation IDs |
| `notifications/` | Notification contract/native registration/service/runtime hook |
| `reporting/` | AI content reporting (reason codes, Edge calls, idempotency) |
| `search/` | User-scoped record title/original/polished/summary + meeting title/raw/edited transcript search; sanitization, parallel queries, newest-first merge |
Core libs (`src/lib/`): `auth-context`, `auth-redirect`, `auth-capabilities`, `secure-auth-storage` (Keychain + legacy migration), `logout`, `account-exit`, `account-local-data` (central purge), `supabase`, `billing-context` (Play/App Store IAP), `entitlement-context`, `device-context`, `preferences-context`, `mobile-ads-context` (UMP consent + rewarded), `native-config`, `pkce-s256`, `random-id`, `audio-recorder`, `e2e-runtime-bootstrap`.
@ -87,10 +92,12 @@ Source of truth for status is `docs/v3/MOBILE_APP_COMPLETION_SSOT.md` sections 0
| Area | Status |
|---|---|
| Auth / account | `[~]` Implemented + Jest GREEN; external OAuth consent→callback and logout/delete/provider device E2E pending |
| Onboarding / theme / accessibility | `[x]` device journey GREEN (some checklist rows still `[ ]` for a11y specifics) |
| Onboarding | `[x]` previous first-run device journey GREEN; this does not verify the new v4 shell on every viewport/theme |
| v4 shell / theme / accessibility | `[x]` shell journey at 320dp dark and 411dp light; `[~]` broader design-system/a11y proof because TalkBack/font-scale/rotation remain |
| Recording / transcription / Talk | `[x]` core path GREEN on API 34 (FGS, recovery, local Whisper, cloud fail-closed, TTS) |
| Data portability | `[x]` export/restore + share E2E GREEN |
| History / meetings | `[x]` real data + meeting creation contract GREEN; audio timestamp/speaker + cross-app E2E pending |
| Record+meeting unified search | `[~]` service/row tests and both canonical runtime journeys (both source results → details) GREEN; mobile raw-text overfetch/no cancellation and FTS/index/large-data performance remain unproved |
| Teams / push | `[~]` teams GREEN; real FCM delivery needs Firebase/FCM credentials |
| Billing / ads | `[~]` test ads + Google Play verification code GREEN; live store purchase/restore/settlement and production AdMob serving **RED (external)** |
| Admin | `[x]` mobile admin role E2E GREEN (ordinary/manager/admin/super_admin, stale JWT) |
@ -104,11 +111,16 @@ Source of truth for status is `docs/v3/MOBILE_APP_COMPLETION_SSOT.md` sections 0
The Expo skeleton (`apps/mobile`) was deleted on 2026-09-13: it duplicated auth/data logic, shipped a dev-bypass login, and was outside the workspace and CI. `scripts/ci/sync-version.mjs` and `package-lock.json` no longer reference it. `apps/mobile-rn` is the sole mobile runtime; git history retains the old app.
The v4 shell also removed `DashScreen` and `WorksHubScreen`. Home replaces the dashboard entry; first-class tabs and More preserve the former WorksHub destinations. The WorksHub-only local tool filter and pinning were intentionally retired to avoid a second navigation taxonomy and local ordering state. This removed navigation UI, not record/meeting/tool content; an unused `works-hub-pins.v1` AsyncStorage key may remain on upgraded installs.
---
## 6. Tests & evidence
- Jest: `apps/mobile-rn/__tests__/` — per SSOT 57 suites / 353 tests (auth, privacy purge, admin, billing, recording language, generation idempotency, templates/memos, STT quota integration).
- Jest: `npm --prefix apps/mobile-rn test` — current full tree **86 suites / 589 tests PASS** (2026-09-28). v4-focused coverage includes `navigation-shell.test.tsx`, `screen-header.test.tsx`, `theme-parity.test.ts`, `ui-native-theme.test.tsx`, `unified-search-service.test.ts`, and `unified-search-result-row.test.tsx`.
- Maestro E2E: `apps/mobile-rn/.maestro/` + evidence in `.maestro-output/`.
- v4 local runtime: `apps/mobile-rn/.maestro/navigation-shell-search-local.yaml`; 320dp dark **1/1 PASS, 88 s** at `scratch/design-audit/wave45/navigation-shell-search-320-dark-verified.xml`, and 411dp light **1/1 PASS, 100 s** at `scratch/design-audit/wave45/navigation-shell-search-411-light-verified.xml`.
- Runtime APK: `com.d3ro.voice`, versionName `1.9.0-e2e.20260928.2`, versionCode `202609282`, 147063583 bytes, SHA-256 `96EBA2A47E54033EB0DEFCE4E4D282F1D06F162A5F7DF9457E99609D98745D58`. This is a local E2E artifact, not a production-signed APK/AAB or Play release.
- Committed evidence summary: [`docs/evidence/wave45-mobile-shell-search-2026-09-28.md`](../evidence/wave45-mobile-shell-search-2026-09-28.md).
- Android instrumentation: `apps/mobile-rn/android/app/src/androidTest` (CSPRNG + incoming share).
- Build verifiers: `scripts/ci/verify-android-artifact.mjs`, `verify-android-app-links.mjs`, `verify-mobile-build-config.mjs`, `verify-mobile-release-boundary.mjs`.

View file

@ -68,6 +68,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| MEM-12 | Voice actions (OS automation) | [x] | [x] | [x] | [x] | Desktop `VoiceActionService`; web `ActionRunner` (simulated); mobile `ActionsScreen` |
| MEM-13 | Cross-surface data sync | [~] | [x] | [x] | [x] | **Desktop rewritten 2026-09-27** (`services/sync/SyncEngine.ts`): persistent outbox (offline/restart safe), server-clock keyset cursors per user DB with pagination, pending local edits never overwritten by pull, deletions both ways through `sync_tombstones`, per-row failure isolation, Realtime that actually connects (`ws` transport). Covers history (+title/favorite), dictionary, meetings, meeting memos/documents, memo tags, custom commands, dictation + meeting-doc templates; plus device registration. Evidence: `apps/desktop/tests/main/sync/SyncEngine.test.ts` (20) + `apps/desktop/tests/integration/cross-device-sync.supabase.test.ts` (5, local Supabase stack). `[~]` until migration `20260927000034` is deployed to production and a desktop build is exercised against a phone (GAP-SYNC-05); knowledge/audio/settings still local (GAP-SYNC-06) **2026-09-27 second batch:** knowledge base, recordings (upload + remote playback) and the mobile `user_settings` fields that mean the same thing on both sides (ko/en locale, system/light/dark theme, auto-polish on/off, active user command) now sync too; remote setting changes reach the open window (`app:dataChanged`, `config:changed`). Production DB is at migration `0035` and all Edge Functions are redeployed from the repo |
| MEM-14 | Memo tag search | [x] | [ ] | [x] | [x] | |
| MEM-15 | Unified record + meeting search | [~] | [ ] | [~] | [-] | Shared `@d3ro/core/types` result contract. Desktop: SQLite `UnifiedSearchService` → `unifiedSearch:query` → `UnifiedSearchPage`, source/focused tests GREEN but no Electron manual proof. Mobile: user-scoped Supabase history+meetings search, service/row tests plus 320dp dark and 411dp light runtime journeys (both result types → details) GREEN. `[~]` remains because separate `transcripts` segments are absent, mobile fetches raw text without in-flight cancellation, and neither surface has FTS/index or large-data performance proof (`11` GAP-UX-05; `docs/evidence/wave45-mobile-shell-search-2026-09-28.md`). |
---
@ -201,9 +202,9 @@ end-to-end behaviour has **not been verified by typing in a real app** (`11` GAP
| SHELL-10 | Download center / releases | [-] | [x] | [-] | [x] | Web `/download`, admin `/releases`, Forgejo |
| SHELL-11 | Landing site / legal pages | [-] | [-] | [-] | [-] | `site/` — the only landing/download/legal/invite/assetlinks surface (Wave 3, 2026-09-26: copies in api-server wwwroot and apps/web removed; 404.html; hero loops a copy of the app recording capsule). Prices/URLs from `packages/core` plan-catalog/web-urls — see `design.md` |
| SHELL-12 | Notifications (in-app / desktop) | [x] | [ ] | [x] | [x] | Desktop events; mobile push |
| SHELL-13 | Desktop information architecture (2026-09-28) | [x] | [-] | [ ] | [-] | 1급 홈·기록·회의 + 2급 "도구"(AI 대화·명령어·사전·지식) + 설정. `renderer/navigation/app-navigation.tsx`(라우트·params·back, Ctrl+1..3 / Ctrl+,), `components/shell/AppSidebar.tsx`(버튼 + aria-current, 아래 한 줄 = 조회한 상태만), 상태바 삭제. 모바일 동일 구조는 물결 ④(`11` GAP-UX-04). 결정 정본: 루트 `design.md` 2026-09-28 |
| SHELL-14 | Home ("오늘") | [x] | [-] | [ ] | [-] | 대시보드를 대신한다: 받아쓰기 키 · 새 회의·자막 시작 · 조회된 문제만 · 한 줄 요약 · 최근 기록 6/회의 4 · 파일 전사 · 무료 한도. `pages/HomePage.tsx` |
| SHELL-15 | Design system v4 "조용한 계기" | [x] | [~] | [~] | [-] | `packages/ui/src/theme-raw.ts` 원시값 SSOT(렌더러 theme.ts·팝업 theme-vars.ts 가 파생), 평면 표면·텍스트 3단 AA·accent.solid/ink. 웹·관리자는 토큰 파생으로 평면화만 적용, 모바일(ui-native) 동기화는 물결 ④ |
| SHELL-13 | Desktop/mobile information architecture (2026-09-28) | [x] | [-] | [x] | [-] | 1급 홈·기록·회의 + 2급 도구. Desktop: `renderer/navigation/app-navigation.tsx`, `AppSidebar`, status bar removed. Mobile: visible Home/Records/Meetings/More tabs, hidden Record route, floating three-action Record FAB, More tool/account grouping; full Jest and the canonical 320dp dark/411dp light runtime journeys are GREEN (`11` GAP-UX-04). `DashScreen`/`WorksHubScreen` were removed; WorksHub-only local filter/pinning was intentionally retired while every destination remains in tabs/More and user content is untouched. Decision SSOT: root `design.md`. |
| SHELL-14 | Home ("오늘") | [x] | [-] | [x] | [-] | Desktop `pages/HomePage.tsx`: recording key, starts, blockers, summary, recent 6/4, file transcription, bounded usage. Mobile `HomeScreen`: today summary, recent records 5/meetings 3, unified-search and plan entry; canonical 320dp dark and 411dp light runtime journeys GREEN. |
| SHELL-15 | Design system v4 "조용한 계기" | [x] | [~] | [~] | [-] | `packages/ui/src/theme-raw.ts` raw-value SSOT; flat surfaces, three text levels with AA contrast, accent.solid/ink. `packages/ui-native` mirrors dark/light semantics and exposes 48dp control/numeral/motion tokens; focused parity/provider/header tests plus 320dp dark/411dp light runtime are GREEN. `[~]` remains because actual TalkBack, font-scale, and rotation proof is absent; `ScreenHeader` covers new/converted v4 flows, not every historical stack screen. |
---

View file

@ -37,8 +37,8 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| GAP-UX-01 | Desktop | 회의 "내 메모"(상세 탭)는 이 기기 localStorage 에만 저장된다 — 기기 간 동기화되지 않는다. 끝난 회의에는 addMemo 가 거부된다(`MeetingModeService.ts:383`). | `components/meeting/meeting-notepad-storage.ts`, `MeetingModeService` | `[ ]` 2026-09-28: 끝난 회의의 사용자 노트를 받을 필드(또는 종료 후 addMemo)와 동기화 어댑터가 필요. |
| GAP-UX-02 | Desktop | 기록 조회에 모드·즐겨찾기 조건, 태그+검색 조합이 없어 화면이 불러온 페이지 위에서 거른다(조건이 까다로우면 여러 페이지를 불러와야 보인다). 오프셋 페이지라 사이 삽입·삭제 때 누락·중복 가능. 기록 제목·본문 편집 없음. 마크다운 내보내기는 태그 붙은 기록만·저장 위치 선택 불가. | `HistoryService`, `history:getAll`, `MemoService.exportMarkdown` | `[ ]` 2026-09-28 |
| GAP-UX-03 | Desktop | 회의 요약 항목 → 전사 구간 근거 링크 없음(요약·전사를 잇는 인용 데이터가 없다). | `MeetingSummaryService`, 회의 문서 생성 | `[ ]` 2026-09-28: 레퍼런스(Granola·Otter)의 핵심 패턴 — 생성 시 구간 인용을 함께 저장해야 한다. |
| GAP-UX-04 | Mobile | 모바일 정보 구조 개편(탭 4 홈·기록·회의·더보기 + 떠 있는 녹음 버튼의 시작 메뉴) 미착수. | `apps/mobile-rn/src/navigation/TabNavigator.tsx` | `[ ]` 2026-09-28: 물결 ④. |
| GAP-UX-05 | Desktop+Mobile | 기록+회의 통합 검색 없음(회의 전사는 어떤 검색으로도 못 찾는다). | 렌더러·모바일 검색, `MeetingModeService` | `[ ]` 2026-09-28: 물결 ⑤. 스키마 변경 없이 조회 추가. |
| GAP-UX-04 | Mobile | 모바일 정보 구조 개편: visible Home/Records/Meetings/More 4탭, 숨은 Record route, 세 동작 FAB, Home/More, 새/전환된 v4 흐름의 `ScreenHeader`와 48dp back target. | `apps/mobile-rn/src/navigation/TabNavigator.tsx`, `components/{RecordFab,ScreenHeader}.tsx`, `screens/{Home,More}Screen.tsx` | `[x]` 2026-09-28: 전체 Jest 86 suites/589 tests, 320dp dark 1/1 PASS(88초), 411dp light 1/1 PASS(100초). Canonical 보고서는 `scratch/design-audit/wave45/navigation-shell-search-{320-dark,411-light}-verified.xml`, 추적 요약은 `docs/evidence/wave45-mobile-shell-search-2026-09-28.md`. `DashScreen`/`WorksHubScreen`은 삭제했고 WorksHub의 로컬 도구 검색·고정은 복잡도 축소를 위해 의도적으로 종료했다. 목적지는 탭/More에 보존됐고 콘텐츠 삭제는 없으며 비활성 `works-hub-pins.v1` 키는 남을 수 있다. TalkBack/font-scale/rotation은 이 셸 gap을 다시 열지 않고 Q-021 `[~]`로 추적한다. |
| GAP-UX-05 | Desktop+Mobile | 기록+회의 통합 검색. 두 원본은 분리 유지하고 결과만 `source + id`로 상세 화면에 연결한다. | `@d3ro/core/types`, desktop `UnifiedSearchService`/`UnifiedSearchPage`, mobile `features/search`/`UnifiedSearchScreen` | `[~]` 2026-09-28: 모바일 service/row tests와 320dp dark·411dp light에서 기록·회의 결과→각 상세 경로가 GREEN. 데스크톱은 서비스·IPC·preload·페이지와 focused tests까지만 있으며 Electron `Ctrl+K`/결과 이동 수동 증거가 없다. 회의 검색은 `meetings.raw_transcript`/`edited_transcript` 집계 필드까지라 별도 `transcripts` 구간이 빠져 있다. 모바일은 일치 확인에 원문 필드를 내려받아 과전송하고 실행된 요청 취소가 없다. 두 표면 모두 FTS/index와 실제 대규모 데이터 성능 증거가 없다. Electron 수동 검증, 구간 검색 설계, 모바일 projection/RPC·취소, 검색 규모 기준과 perf proof 뒤에 표면별 `[x]`를 판단한다. |
| GAP-UX-06 | Desktop | main 쪽 정리 필요: `STTManager.ts:53-129` 공급자 설명 한국어 하드코딩·마케팅 문구("100% 완전 오프라인", "Industry Benchmark") — 렌더러는 더 이상 쓰지 않는다 / audio TEST_DEVICE 가 deviceId 무시 / 사전 `incrementUsage` 호출처 없음(사용 횟수 항상 0) / 사전 발음 필드가 인식 힌트·치환에 쓰이지 않음 / 대화 세션 다시 듣기 IPC 없음 / preload `instruction.getAll` 이 `unknown[]` / 지식 추가 실패 판정이 문구 비교. | 각 파일 | `[ ]` 2026-09-28 (도구·설정 개편 에이전트 보고) |
| GAP-UX-07 | Desktop | 2026-09-28 개편 문구는 ko·en 만 — 다른 8개 로케일은 en 폴백. 온보딩 Ollama 모델 크기 표기(`onboarding.llmModelSize` 9.6GB)와 안내 모달의 크기가 다르다. | `packages/i18n/src/locales/*` | `[ ]` 2026-09-28 |
| GAP-TEAM-02 | Team | 브라우저에서 초대를 수락하는 경로가 없다. 웹 `accept-invite` 페이지는 발급 링크가 가리키지 않고 로그인 후 토큰을 읽는 곳이 없어 끊겨 있었으므로 삭제했다(사이트 `/accept-invite/`는 앱 딥링크만). | `site/public/accept-invite/`, `server/supabase/functions/team-accept` | `[ ]` 2026-09-26: 데스크톱 전용 사용자를 위한 웹 수락 흐름이 필요하면 `/app` 아래에 다시 설계. |
@ -119,14 +119,14 @@ These are the mobile SSOT rows still `[ ]` / `[~]`. Do not duplicate the full te
|---|---|---|
| G (governance) | G-002, G-006, G-007, G-008, G-009, G-010, G-011 | Type/error/identity consolidation, legacy bundle separation, credential rotation |
| A (auth/account) | A-001..A-013, A-018; A-014..A-017 `[~]` | Session restore, signup, deep links, OAuth, profile, provider E2E |
| O (onboarding/a11y) | O-001..O-012 | Onboarding branches, permissions, tutorial, a11y, full ko/en |
| O (onboarding/a11y) | O-001..O-007, O-009, O-011; O-008/O-010/O-012 `[~]` | Onboarding branches, permissions, tutorial; app-wide theme coverage, TalkBack/font-scale/rotation; full-app ko/en audit |
| D (data/sync/offline) | D-001..D-014 | Schema/RLS, devices, cross-surface read/write, offline queue, conflict policy |
| R (record/transcribe) | R-001..R-009, R-011..R-015, R-017..R-020; R-016 `[x]` | Permissions, recorder, FGS, upload queue, job state, sharing |
| H (history/meetings) | H-001..H-005, H-007..H-015; H-006 `[x]` | Pagination, filters, detail, meeting timeline, docs, diarization, cross-app |
| F (feature parity) | F-001..F-010, F-013, F-014; F-011/F-012 `[x]` | Dashboard, dictionary, commands, actions, chat, conversation, knowledge |
| H (history/meetings) | H-001..H-005, H-007..H-015; H-016 `[~]`; H-006 `[x]` | Pagination, filters, detail, meeting timeline/docs/diarization/cross-app; unified-search query/cancellation/performance gaps |
| F (feature parity) | F-003..F-010, F-013, F-014; F-001 `[~]`; F-002/F-011/F-012 `[x]` | Mobile Home dashboard subset, commands/actions/chat/conversation/knowledge |
| T (teams/admin/notify) | T-001..T-005, T-009..T-012; T-006..T-008, T-013 `[x]` | Team CRUD/roles, push delivery/deep links, notification settings |
| M (monetization) | M-025, M-027; M-013 `[~]` | Play Billing license test, Payple webhook signature |
| Q (quality/release) | Q-012..Q-014, Q-019..Q-022, Q-026; Q-010/Q-011/Q-023/Q-027/Q-028 `[~]` | Emulator scripts, OAuth E2E, billing E2E, visual/a11y gates, env unification, production AAB |
| Q (quality/release) | Q-012..Q-014, Q-019, Q-020, Q-022, Q-026; Q-010/Q-011/Q-021/Q-023/Q-027/Q-028 `[~]` | Emulator scripts, OAuth/billing E2E, full journey; TalkBack/font-scale/rotation, env unification, production AAB |
---

View file

@ -9,6 +9,7 @@
> 제품화 재개 핸드오프: [`MOBILE_PRODUCTIZATION_HANDOFF_2026-08-21.md`](./MOBILE_PRODUCTIZATION_HANDOFF_2026-08-21.md)
> 이 문서는 이전 `docs/v3/00-mobile-master-plan.md`의 미래 로드맵을 대체하는 구현·검증 정본이다.
> 2026-09-26: `.github/workflows`는 삭제되었고 CI는 `.forgejo/workflows`가 정본이다 — 아래 날짜별 기록에 남은 `.github/workflows` 언급은 해당 시점의 스냅샷이다.
> 2026-09-28 Wave 4/5 로컬 증거: [`../evidence/wave45-mobile-shell-search-2026-09-28.md`](../evidence/wave45-mobile-shell-search-2026-09-28.md). 이 E2E APK와 런타임 보고서는 production release 증거가 아니다.
## 0. 2026-09-16 출시 게이트 스냅샷
@ -94,14 +95,18 @@
| 묶음 | 현재 판정 | 확인된 증거 / 남은 게이트 |
|---|---|---|
| Auth·계정 | 구현·자동화 GREEN, 외부 OAuth·privacy purge 기기 E2E 대기 | auth/signup/invite 경계는 최신 exact APK `.11`에서 PASS. Keychain 세션, 중앙 `purgeAllAccountLocalData`, cold no-session·SIGNED_OUT·refresh session loss·A→B 전환·실패 재시도 경계는 전체 모바일 57 suites/353 tests에 포함돼 GREEN. 실제 token-expiry 강제 로그아웃·logout/delete/provider 연결/해제 기기 E2E와 Google/GitHub/Apple 동의→callback은 남음 |
| 온보딩·테마 | 구현·기기 journey GREEN | 신규/기존 분기, skip/replay, 계정별 preference merge, system/light/dark, reduced motion, 알림·마이크 권한 상태 구현. production preference `0→skip→1`, Android PID 유지, 5.8초 내 Main, navigation warning 0을 실측 |
| 온보딩 | 기존 구현·기기 journey GREEN | 신규/기존 분기, skip/replay, 계정별 preference merge, 알림·마이크 권한 상태 구현. production preference `0→skip→1`, Android PID 유지, 5.8초 내 Main, navigation warning 0을 실측. 이 과거 증거를 2026-09-28 v4 셸의 전 테마·viewport 증거로 재사용하지 않는다 |
| v4 셸·테마·접근성 | 셸 `[x]`, 디자인 시스템·접근성 `[~]` | visible Home/Records/Meetings/More 4탭, hidden Record route, 세 동작 FAB, Home/More, 새/전환된 v4 흐름의 `ScreenHeader`, ui-native dark/light 의미 토큰과 48dp control을 구현했다. 전체 Jest 86 suites/589 tests, 320dp dark 1/1 PASS(88초), 411dp light 1/1 PASS(100초). 기존 stack 화면 전부의 헤더 전환을 뜻하지 않으며 실제 TalkBack/font-scale/rotation은 남음 |
| 녹음·전사·Talk | 핵심 경로 GREEN | API 34 microphone FGS, 지속 알림, pause/resume/discard, 실제 통화 audio-focus, 프로세스 kill 후 WAV 복구, 파일 import, cloud STT fail-closed, on-device Whisper, Claude SSE, Android TTS를 통합 APK에서 확인. STT provider 호출 전 원자 quota reservation·실패 환불은 20-way 동시성으로 검증. Android `ACTION_SEND` 수신은 허용 MIME·크기·provider 예외·MP4 AAC 추출을 fail-closed로 구현했고 API 34 instrumentation 4/4(CSPRNG 1 + share 3)와 별도 UID 외부 ContentProvider APK→`ACTION_SEND`→Record→durable queue→cancel cleanup E2E가 GREEN |
| 데이터 portability | 구현·공유 E2E GREEN | 00021 atomic export/restore, 손상/타 사용자/충돌 rollback, legacy import를 local integration으로 확인. `portable_exports`→FileProvider `shared_exports` 경계와 PDF Print/DOCX chooser·30초 cleanup을 실제 기기에서 확인 |
| History·회의 | 실제 데이터·회의 생성 계약 GREEN | Supabase history pagination/search/favorite/CAS, Web↔mobile history E2E, meetings/memos/documents RLS/CAS, meeting recording lifecycle 구현. 00025의 제목·참석자·4개 언어·소유 템플릿 원자 생성은 2계정 43 assertions와 competing-payload PT409로 검증. speaker/audio timestamp와 최종 교차 앱 E2E는 남음 |
| 기록+회의 통합 검색 | `[~]` source/full Jest + 320dp dark·411dp light runtime GREEN | 사용자 범위 history title/original/polished/summary와 meetings title/raw/edited transcript를 병렬 검색해 최신순으로 합치고 각 상세로 이동한다. 두 viewport/theme에서 두 source→detail을 실측했다. 다만 별도 `transcripts` 구간은 검색하지 않고, 모바일 raw-text 과전송·실행 요청 미취소, FTS/index·대규모 성능 미검증이 남음 |
| Teams·Push | 팀 GREEN, 외부 Push 대기 | production 17 assertions와 local 36 assertions로 원자 팀/초대/RLS/role/device token 계약 확인. durable push outbox 00015~00020과 logout device `1→0` 확인. 실제 FCM 수신은 Firebase/FCM 자격 증명·cron 배포가 필요 |
| Billing·Ads | test 광고와 production AdMob identity/SSV 준비, store·실제 지급 E2E RED | Google Play restore는 실제 store list→모든 token 서버 검증→entitlement snapshot 일치 후에만 성공. Google 공식 test adaptive banner와 rewarded `AdActivity` 완주를 실측했다. production app `ca-app-pub-1039714767792854~6427959892`, banner `/9840591290`, rewarded `/2255790918`, `50 cloud_ai_tokens`, SSV URL, Edge v13, durable receipt 원장은 SSOT에 반영했다. 그러나 live는 `검토 필요`, `광고 게재 제한`, store 미연결, 결제 프로필 미완료다 |
| Admin | 서버·Web·모바일 E2E와 production DB·Edge 배포 GREEN | .NET 21/21, 00023 관리 integration 25 assertions, Edge/JWT 47 assertions와 TLS Chromium E2E GREEN. 모바일 API 34에서 ordinary/manager/admin/super_admin 및 stale 강등을 55 assertions로 실측: Free test 광고, Pro 광고 미요청, 관리자 메뉴/API 차단, 구독 수정, 역할 경계·변경, 삭제 확인·취소, stale JWT 403와 민감 state purge가 모두 PASS. fixture 잔존 0. production 00023 및 admin Edge 4개 배포 후 익명 GET은 모두 `401 authentication_required` + `Cache-Control: no-store`로 재검증 |
| 품질·배포 | exact-APK 에뮬레이터 gate GREEN, production 배포 RED | mobile ESLint 0, typecheck GREEN, 전체 Jest 57 suites/353 tests GREEN은 2026-08-24 실행 증거다. universal non-debuggable TEST-DEMO `0.0.0-e2e.20260821.11`/`202608221`을 API 34에 fresh install해 cold launch·auth/signup/invite를 검증했지만 production artifact가 아니다. 2026-08-29 release identity `1.1.0`/`1010001`, local upload key, Ed25519 evidence keypair, AdMob identity는 확정했지만 production Firebase·CI secret 주입·AAB·Play track·Fold6 Play 설치 증거는 없다 |
| 품질·배포 | current local source/runtime gate GREEN, production 배포 RED | 2026-09-28 현재 tree 전체 Jest 86 suites/589 tests GREEN. 로컬 E2E APK는 `com.d3ro.voice`, `1.9.0-e2e.20260928.2`/`202609282`, 147063583 B, SHA-256 `96EBA2A47E54033EB0DEFCE4E4D282F1D06F162A5F7DF9457E99609D98745D58`이며 두 canonical Maestro 보고서가 GREEN이다. 이것은 production-signed artifact가 아니므로 Firebase·CI secret 주입·AAB·Play track·Fold6 Play 설치 게이트는 그대로 RED다 |
**v4 제거 범위:** `DashScreen`과 `WorksHubScreen`을 삭제했다. Home과 1급 탭/More가 기존 목적지를 보존하며 사용자 콘텐츠는 삭제하지 않았다. WorksHub 전용 로컬 도구 검색·고정은 복잡도 축소를 위해 의도적으로 종료했고, 비활성 `works-hub-pins.v1` AsyncStorage 키는 업그레이드 설치에 남을 수 있다.
## 3. 플랫폼 동등성 원칙
@ -164,11 +169,11 @@
- [ ] O-005 첫 녹음→전사→공유를 안내하는 단계별 튜토리얼
- [ ] O-006 주요 탭과 파일 가져오기·동기화·결제 관리 튜토리얼
- [ ] O-007 튜토리얼 다시 보기·건너뛰기·완료 상태 동기화
- [ ] O-008 system/light/dark 테마와 D3RO 토큰 기반 렌더링
- [~] O-008 system/light/dark 테마와 D3RO 토큰 기반 렌더링 — ui-native v4 dark/light palette parity·provider/component Jest, 320dp dark·411dp light runtime GREEN. 새/전환된 v4 흐름 밖의 앱 전체 테마 적용 감사까지 완료했다는 뜻은 아님
- [ ] O-009 테마·언어·햅틱·자동 교정·STT 옵션 저장 및 기기간 동기화
- [ ] O-010 글자 확대, TalkBack label/role, focus order, 48dp 터치 영역, 색 대비 검증
- [~] O-010 글자 확대, TalkBack label/role, focus order, 48dp 터치 영역, 색 대비 검증 — touched controls/ScreenHeader 48dp와 theme contrast source tests GREEN, 실제 TalkBack·focus order·font scale·rotation은 대기
- [ ] O-011 reduced motion과 애니메이션 비활성 경로
- [ ] O-012 한국어/영어 필수 전체 번역과 하드코딩 문자열 제거
- [~] O-012 한국어/영어 필수 전체 번역과 하드코딩 문자열 제거 — v4 셸·FAB·통합 검색과 touched enum label은 ko/en typed keys 사용, 앱 전체 감사 대기
### D — 데이터 가져오기·동기화·오프라인
@ -229,10 +234,11 @@
- [ ] H-013 회의 목록 search/filter/sort/delete/share
- [ ] H-014 화자 구분 상태·화자명 편집·미지원 fallback
- [ ] H-015 Web↔모바일 동일 회의 교차 편집 E2E
- [~] H-016 홈 기록+회의 통합 검색 — 사용자 범위 history/meetings 병렬 조회, 입력 정규화·최신순 merge·record/meeting 상세 이동 service/row Jest와 320dp dark·411dp light runtime GREEN. 별도 transcript segment 미검색, 모바일 raw-text 과전송·실행 요청 미취소, FTS/index·대규모 성능 미검증 때문에 부분 완료
### F — 데스크톱/Web 전체 기능의 모바일 화면
- [ ] F-001 Dashboard 통계·사용량·처리 job·구독·동기화 상태를 실제 데이터로 표시
- [~] F-001 Home 통계·사용량·처리 job·구독·동기화 상태를 실제 데이터로 표시 — 실제 dashboard stats + 최근 기록 5/회의 3 + Free plan 진입 구현, 320dp dark·411dp light runtime GREEN. 처리 job·동기화 상태 전체 범위는 대기
- [x] F-002 Dictionary 목록/검색/추가/편집/삭제/import/export/sync — CRUD/sync GREEN, export는 `data-portability` CSV/TXT, import는 CSV/JSON/TXT
- [ ] F-003 Custom instructions 목록/활성화/CRUD/reorder/sync
- [ ] F-004 Voice commands 목록/활성화/CRUD와 모바일 실행 가능한 action 구분
@ -316,8 +322,8 @@
- [x] Q-017 Web seeded 데이터→모바일 조회/편집→Web 확인 sync E2E
- [x] Q-018 Free banner/rewarded test ad와 Pro 광고 미요청 E2E — official Google test ad 실측, ordinary Free 광고 노출과 manager/admin Pro 광고 비노출·미요청 API 34 PASS
- [ ] Q-019 Play Billing test purchase/restore/manage E2E
- [ ] Q-020 모든 탭·설정·팀·관리·실패 복구 journey E2E
- [ ] Q-021 회전·폰트 확대·TalkBack·dark/light·작은/큰 화면 visual gate
- [ ] Q-020 모든 탭·설정·팀·관리·실패 복구 journey E2E — v4 4탭/FAB/More/통합 검색의 320dp dark·411dp light 부분 journey만 GREEN; 전체 범위를 대신하지 않음
- [~] Q-021 회전·폰트 확대·TalkBack·dark/light·작은/큰 화면 visual gate — 이 v4 journey의 320dp dark·411dp light는 GREEN; 회전·폰트 확대·TalkBack 실기기 증거는 대기
- [ ] Q-022 airplane mode·process kill·token expiry·server 5xx·중복 tap 회복 E2E
- [~] Q-023 앱 로그 crash/ANR/React error/민감정보 0건 확인 — exact `.9` auth/signup/invite/small-screen 및 admin role journeys에서 fatal/ANR/React fatal 0, production provider journey 민감정보 로그 감사 대기
- [x] Q-024 APK package/version/signature/SHA-256와 설치된 앱 버전 증거
@ -361,10 +367,14 @@
| Historical baseline | `git status --short --branch` | 2026-08-21 당시 `main`이 remote보다 2 commit 앞서고 dirty 변경이 다수였다. 현재 release SHA로 재사용하지 않는 역사 증거다 | 본 문서 1절 | 2026-08-21 |
| Mobile lint | `npm --prefix apps/mobile-rn run lint` | GREEN, warning/error 0 (`--max-warnings=0`) | `apps/mobile-rn/.eslintrc.js`, `package.json` | 2026-08-21 |
| Mobile typecheck | `npm --prefix apps/mobile-rn run typecheck` | GREEN | `apps/mobile-rn/tsconfig.json` | 2026-08-21 |
| Mobile unit | `npm --prefix apps/mobile-rn test` | 전체 통합 tree 57 suites / 353 tests GREEN; 중앙 account-local privacy purge, cold/session-loss/A→B auth 경계, secure logout, identity safety, mobile admin, rewarded/banner retry·template/memo·meeting creation·recording language·generation idempotency 포함 | `apps/mobile-rn/__tests__` | 2026-08-24 |
| Historical mobile unit | `npm --prefix apps/mobile-rn test` | 당시 전체 통합 tree 57 suites / 353 tests GREEN; 현재 tree 수치가 아님 | `apps/mobile-rn/__tests__` | 2026-08-24 |
| Current mobile unit | `npm --prefix apps/mobile-rn test` | 전체 tree **86 suites / 589 tests PASS**. navigation shell/header/theme와 unified-search service/result-row 계약을 포함 | `apps/mobile-rn/__tests__`, `docs/evidence/wave45-mobile-shell-search-2026-09-28.md` | 2026-09-28 |
| Wave 4/5 local runtime APK | output metadata + file size/SHA-256 + installed/runtime identity 확인 | `com.d3ro.voice`, versionName `1.9.0-e2e.20260928.2`, versionCode `202609282`, 147063583 B, SHA-256 `96EBA2A47E54033EB0DEFCE4E4D282F1D06F162A5F7DF9457E99609D98745D58`. 로컬 E2E artifact이며 production-signed APK/AAB가 아니다 | `apps/mobile-rn/android/app/build/outputs/apk/e2e/{app-e2e.apk,output-metadata.json}`, `docs/evidence/wave45-mobile-shell-search-2026-09-28.md` | 2026-09-28 |
| Mobile v4 320dp dark runtime | `apps/mobile-rn/.maestro/navigation-shell-search-local.yaml` | **1/1 PASS, 0 failure, 88초**: visible 4 tabs, FAB open/back close, Home unified search, record+meeting results→both details, Records/Meetings/More navigation | `scratch/design-audit/wave45/navigation-shell-search-320-dark-verified.xml`, `docs/evidence/wave45-mobile-shell-search-2026-09-28.md` | 2026-09-28 |
| Mobile v4 411dp light runtime | same journey on 411dp light | **1/1 PASS, 0 failure, 100초**. 두 보고서는 shell/viewport/theme runtime proof이며 TalkBack/font-scale/rotation 또는 production release proof가 아니다 | `scratch/design-audit/wave45/navigation-shell-search-411-light-verified.xml`, `docs/evidence/wave45-mobile-shell-search-2026-09-28.md` | 2026-09-28 |
| Supabase local | migration-up + shadow replay 00001~00028 및 domain integration | 기존 domain integration에 더해 AdMob receipt 원장의 unknown/ineligible/cooldown replay, account-delete unlink, ACL, 교차 사용자·동시 요청을 검증했다. mobile-platform 85/85, reward race 2회 연속, SSV Deno 6/6, db lint error 0, shadow diff empty, fixture cleanup 0 | `server/supabase/migrations/20260821000028_ad_reward_receipt_replay_protection.sql`, `server/supabase/tests`, `server/supabase/functions/_shared/admob-ssv.test.ts` | 2026-08-24 |
| Android build | `:app:assembleDebug -PreactNativeArchitectures=arm64-v8a,x86_64` | universal BUILD SUCCESSFUL; 두 ABI의 screens/gesture/Nitro/Whisper SO 완전성 확인 후 x86_64 clean cold launch GREEN, SHA-256 `E4647A4C041174DF2B2F094EF7BB61B4B01DD10A379DB32CCBE4105231E04A7C` | `apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk`, `scratch/demo/d3ro-mobile-dual-abi-installed.png` | 2026-08-21 |
| Latest Android E2E test artifact | JDK 17 `:app:assembleE2e -PreactNativeArchitectures=arm64-v8a,x86_64` + artifact/build-config verifiers | BUILD/verifier GREEN; `com.d3ro.voice`, version `0.0.0-e2e.20260821.11`/`202608221`, non-debuggable, embedded bundle 3,808,068 B·Whisper model 77,691,713 B/SHA 검증, Google test AdMob ID, arm64+x86_64. APK 146,739,935 B, SHA-256 `74035B69DB0A564846DF5ED4B85CA5D0E2289D5BB7D73DEE02C35DFD57380C2B`. API 34 exact-APK fresh install와 cold launch GREEN. production artifact가 아니다 | `apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk`, `scripts/ci/verify-android-artifact.mjs`, `scripts/ci/verify-mobile-build-config.mjs` | 2026-08-21 |
| Historical Android E2E test artifact | JDK 17 `:app:assembleE2e -PreactNativeArchitectures=arm64-v8a,x86_64` + artifact/build-config verifiers | 당시 BUILD/verifier GREEN; `com.d3ro.voice`, version `0.0.0-e2e.20260821.11`/`202608221`, APK 146,739,935 B, SHA-256 `74035B69DB0A564846DF5ED4B85CA5D0E2289D5BB7D73DEE02C35DFD57380C2B`. 위 2026-09-28 artifact로 대체됐지만 당시 auth/cold-launch 증거의 identity로 보존한다. production artifact가 아니다 | historical installed/runtime evidence, `scripts/ci/verify-android-artifact.mjs`, `scripts/ci/verify-mobile-build-config.mjs` | 2026-08-21 |
| Public Forgejo distribution | 최신 E2E TEST-DEMO 고유 asset upload + anonymous redownload hash 대조 | PENDING; `.11` SHA-256 `74035B69…380C2B` 공개 업로드·anonymous redownload 검증은 수행하지 않음 | Forgejo release asset | 2026-08-21 |
| Mobile release boundary | `npm run release:mobile:boundary:test` + source-contract/security/syntax/YAML checks | GREEN; release mode·expected version/package/cert/prod AdMob·Ed25519 signed evidence와 artifact hash 일치, immutable snapshot, hardlink/reparse/path-swap/static APK 우회·overwrite·test AdMob/debug signer/nonrelease 거부를 검증. 실제 production APK/AAB에는 아직 실행하지 않음 | `scripts/ci/mobile-release-evidence-lib.mjs`, `scripts/ci/verify-mobile-release-boundary.mjs`, `.github/workflows/release.yml` | 2026-08-21 |
| Production Android release gate | release/config/artifact/provenance self-tests + Play/Firebase/AdMob live 재확인 | release identity `1.2.0`/`1020001`, Play app ID `4976102237698469110`, package `com.d3ro.voice`, app-signing SHA-256, local upload/evidence key, AdMob identity를 확인했다. Play는 `임시`, 설정 0/11, AAB 0건, closed tester 0/12명, production access disabled다. Firebase project·CI secret 주입·AAB가 없으므로 현재 판정은 RED다 | `release/product-version.json`, `release/android-release-identity.json`, `scripts/ci/verify-mobile-release-config.mjs`, `scripts/ci/verify-android-artifact.mjs`, live consoles | 2026-08-29 |

View file

@ -154,6 +154,10 @@ export const IPC_CHANNELS = {
ADDED: 'history:added'
},
UNIFIED_SEARCH: {
QUERY: 'unifiedSearch:query'
},
DICTIONARY: {
GET_ALL: 'dictionary:getAll',
ADD: 'dictionary:add',

View file

@ -673,6 +673,37 @@ export interface HistorySearchParams {
pageSize: number
}
// ============================================================
// Unified search (기록 + 회의)
// ============================================================
export type UnifiedSearchSource = 'record' | 'meeting'
export type UnifiedSearchMatchedField = 'title' | 'content' | 'transcript'
/**
* 기록과 회의를 한 목록에 표시하기 위한 읽기 전용 검색 결과.
* 원본 데이터는 합치지 않고 source + id 로 각 상세 화면에 연결한다.
*/
export interface UnifiedSearchResult {
source: UnifiedSearchSource
id: string
title: string | null
excerpt: string
matchedField: UnifiedSearchMatchedField
occurredAt: number
}
export interface UnifiedSearchParams {
query: string
limit?: number
}
export interface UnifiedSearchResponse {
query: string
results: UnifiedSearchResult[]
total: number
}
export interface HistoryExportParams {
format: 'json' | 'csv'
from?: string

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} Funktionen",
"mobile.works.searchPlaceholder": "Funktionen suchen",
"mobile.works.pinned": "Angeheftet",
"mobile.works.pin": "Anheften",
"mobile.works.unpin": "Lösen",
"mobile.works.emptySearch": "Keine passenden Funktionen.",
"mobile.works.section.meetings": "Meetings-Arbeit",
"mobile.works.section.team": "Team & Wissen",
"mobile.works.section.data": "Daten & Hinweise",
"mobile.works.historyDescription": "Transkripte von Aufnahmen und Importen suchen und verwalten.",
"mobile.meetings.templateEmpty": "Keine Meeting-Dokumentvorlagen verfügbar. Sie können das Meeting ohne Vorlage starten.",
"mobile.meetings.templateNone": "Ohne Vorlage starten",
"mobile.meetings.templateNoneDesc": "Vorlage später bei der Dokumenterzeugung wählen",

View file

@ -548,6 +548,14 @@
"mobile.hist.metadata": "Metadata",
"mobile.hist.mode": "Mode",
"mobile.hist.status": "Status",
"mobile.hist.mode.dictation": "Dictation",
"mobile.hist.mode.translate": "Translation",
"mobile.hist.mode.command": "Command",
"mobile.hist.mode.caption": "Captions",
"mobile.hist.mode.fileTranscription": "File transcription",
"mobile.hist.status.completed": "Completed",
"mobile.hist.status.cancelled": "Cancelled",
"mobile.hist.status.error": "Error",
"mobile.hist.language": "Language",
"mobile.hist.duration": "Duration",
"mobile.hist.model": "Model",
@ -561,9 +569,24 @@
"mobile.hist.audioResume": "Resume",
"mobile.hist.audioStop": "Stop",
"mobile.hist.audioPending": "Upload status: {{status}}",
"mobile.hist.audioStatus.pending": "Pending",
"mobile.hist.audioStatus.uploading": "Uploading",
"mobile.hist.audioStatus.uploaded": "Uploaded",
"mobile.hist.audioStatus.failed": "Failed",
"mobile.hist.audioStatus.deleted": "Deleted",
"mobile.hist.audioError": "The audio could not be opened safely.",
"mobile.hist.jobs": "Processing jobs",
"mobile.hist.jobsNone": "No processing jobs are linked.",
"mobile.hist.jobKind.transcription": "Transcription",
"mobile.hist.jobKind.summary": "Summary",
"mobile.hist.jobKind.minutes": "Minutes",
"mobile.hist.jobKind.diarization": "Speaker separation",
"mobile.hist.jobKind.export": "Export",
"mobile.hist.jobStatus.queued": "Queued",
"mobile.hist.jobStatus.running": "Running",
"mobile.hist.jobStatus.succeeded": "Completed",
"mobile.hist.jobStatus.failed": "Failed",
"mobile.hist.jobStatus.cancelled": "Cancelled",
"mobile.hist.share": "Share",
"mobile.hist.shareFailed": "The share sheet could not be opened.",
"mobile.hist.editFailed": "The server did not confirm the edit.",
@ -1134,6 +1157,7 @@
"mobile.account.joinedAt": "JOINED",
"mobile.account.lastLogin": "LAST SIGN-IN",
"mobile.account.tier": "PLAN",
"mobile.account.tierUnknown": "Unknown",
"mobile.account.changePassword": "Change password",
"mobile.account.logout": "Log out",
"mobile.account.logoutConfirm": "Log out on this device?",
@ -1529,6 +1553,11 @@
"mobile.templates.deleteBody": "Delete the ‘{{name}}’ template? If it is selected, the safe builtin will be selected instead.",
"mobile.templates.fieldCount": "{{count}} input fields",
"mobile.templates.documentType": "Document type · {{type}}",
"mobile.templates.type.minutes": "Meeting minutes",
"mobile.templates.type.report": "Report",
"mobile.templates.type.ideaNote": "Idea note",
"mobile.templates.type.custom": "Custom",
"mobile.templates.type.mindmap": "Mind map",
"mobile.templates.loading": "Loading cloud templates",
"mobile.templates.saving": "Saving",
"mobile.templates.empty": "There are no templates of this type.",
@ -1570,6 +1599,10 @@
"mobile.memos.error.notFound": "The history item or tag was not found.",
"mobile.memos.error.validation": "Tags must contain 1–80 non-blank characters.",
"mobile.memos.error.server": "The memo tag operation or share could not be completed.",
"mobile.ads.sponsored": "Sponsored",
"mobile.ads.unavailable": "Ad unavailable",
"mobile.ads.loading": "Loading ad",
"mobile.ads.consent": "Checking ad consent",
"mobile.admin.title": "Mobile administration",
"mobile.admin.description": "Current server role: {{role}} · Every read and mutation is reauthorized by the server.",
"mobile.admin.forbiddenTitle": "Administrator access required",
@ -1640,17 +1673,6 @@
"mobile.admin.error.timeout": "The server timed out. You can retry with the same idempotency key.",
"mobile.admin.error.unavailable": "The safe server contract for this administrator feature is not deployed.",
"mobile.admin.error.server": "The server response could not be verified safely, so the operation was stopped.",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} features",
"mobile.works.searchPlaceholder": "Search features",
"mobile.works.pinned": "PINNED",
"mobile.works.pin": "Pin to top",
"mobile.works.unpin": "Unpin",
"mobile.works.emptySearch": "No matching features.",
"mobile.works.section.meetings": "Meeting work",
"mobile.works.section.team": "Team & knowledge",
"mobile.works.section.data": "Data & alerts",
"mobile.works.historyDescription": "Search and manage transcripts from recordings and imports.",
"mobile.meetings.templateNone": "Start without template",
"mobile.meetings.templateNoneDesc": "Pick a template later when generating documents",
"keybinding.mouse.left": "Left Mouse Button",
@ -2547,5 +2569,98 @@
"tools.conversation.clearTitle": "Clear conversation history?",
"tools.conversation.clearBody": "All {{count}} messages will be removed. This can't be undone.",
"tools.conversation.clearConfirm": "Clear",
"tools.conversation.clearFailed": "Couldn't clear the history. Try again."
"tools.conversation.clearFailed": "Couldn't clear the history. Try again.",
"mobile.nav.home": "Home",
"mobile.nav.records": "Records",
"mobile.nav.meetings": "Meetings",
"mobile.nav.more": "More",
"mobile.fab.open": "Open recording menu",
"mobile.fab.close": "Close recording menu",
"mobile.fab.dictation": "Dictation",
"mobile.fab.dictationHint": "Opens the recording screen to start dictating.",
"mobile.fab.meeting": "Meeting recording",
"mobile.fab.meetingHint": "Create a new meeting, then record it from the meeting screen.",
"mobile.fab.import": "Import audio file",
"mobile.fab.importHint": "Pick an audio file on this device to transcribe.",
"mobile.home.title": "Today",
"mobile.home.recordHint": "Tap the record button at the bottom right to start dictation, a meeting recording, or a file import.",
"mobile.home.summarySessions": "{{count}} today",
"mobile.home.summaryWords": "{{count}} words",
"mobile.home.summaryMinutes": "{{minutes}} min recorded",
"mobile.home.summaryStreak": "{{count}}-day streak",
"mobile.home.summaryNone": "Nothing recorded yet today",
"mobile.home.loading": "Loading today's summary",
"mobile.home.loadError": "Couldn't load today's summary.",
"mobile.home.retry": "Try again",
"mobile.home.recentRecords": "Recent records",
"mobile.home.recentMeetings": "Recent meetings",
"mobile.home.seeAll": "See all",
"mobile.home.seeAllRecords": "See all records",
"mobile.home.seeAllMeetings": "See all meetings",
"mobile.home.noRecords": "No records yet. Use the record button to make your first one.",
"mobile.home.noMeetings": "No meetings yet.",
"mobile.home.meetingsLoadError": "Couldn't load recent meetings.",
"mobile.home.freePlan": "You're on the free plan · See plans",
"mobile.home.freePlanHint": "Opens plans and rewards.",
"mobile.more.title": "More",
"mobile.more.section.tools": "Tools",
"mobile.more.section.account": "Account",
"mobile.more.item.talk": "AI chat",
"mobile.more.item.talkDescription": "Talk with AI by voice or text.",
"mobile.more.item.commands": "Commands",
"mobile.more.item.commandsDescription": "Create and run the AI commands you use often.",
"mobile.more.item.dictionary": "Dictionary",
"mobile.more.item.dictionaryDescription": "Register names and terms so they are recognized correctly.",
"mobile.more.item.knowledge": "Knowledge",
"mobile.more.item.knowledgeDescription": "Collect files and notes as searchable knowledge.",
"mobile.more.item.templates": "Templates",
"mobile.more.item.templatesDescription": "Manage dictation and meeting document formats.",
"mobile.more.item.tags": "Record tags",
"mobile.more.item.tagsDescription": "Tag records and find them by tag.",
"mobile.more.item.teams": "Teams",
"mobile.more.item.teamsDescription": "Manage team invites and shared meetings.",
"mobile.more.item.actions": "Voice actions",
"mobile.more.item.actionsDescription": "Confirm spoken requests, then run them as actions.",
"mobile.more.item.notifications": "Notifications",
"mobile.more.item.notificationsDescription": "Manage notification permission and this device's registration.",
"mobile.more.item.portability": "Import & export data",
"mobile.more.item.portabilityDescription": "Download or restore your account data.",
"mobile.more.item.account": "Account",
"mobile.more.item.accountDescription": "Manage your profile, sign-in methods, and password.",
"mobile.more.item.plan": "Plan",
"mobile.more.item.planDescription": "Current: {{tier}} · See plans and rewards.",
"mobile.more.item.devices": "Devices",
"mobile.more.item.devicesDescription": "See and disconnect devices linked to this account.",
"mobile.more.item.settings": "Settings",
"mobile.more.item.settingsDescription": "Appearance, language, recording, and sync.",
"mobile.more.item.admin": "Admin",
"mobile.more.item.adminDescription": "Manage users and reports.",
"mobile.more.tier.free": "Free",
"mobile.more.tier.pro": "Pro",
"mobile.more.tier.pro_plus": "Pro+",
"unifiedSearch.title": "Search",
"unifiedSearch.homeHint": "Find records and meeting transcripts in one place.",
"unifiedSearch.description": "Search record content, meeting titles, and transcripts together.",
"unifiedSearch.placeholder": "Search records and meetings",
"unifiedSearch.promptTitle": "Enter something to find",
"unifiedSearch.promptDescription": "Search record content, meeting titles, and meeting transcripts.",
"unifiedSearch.noResultsTitle": "No matching results",
"unifiedSearch.noResultsDescription": "No records or meetings matched “{{query}}”.",
"unifiedSearch.loading": "Searching",
"unifiedSearch.loadErrorTitle": "Search failed",
"unifiedSearch.loadErrorDescription": "There was a problem reading saved records and meetings. Try again.",
"unifiedSearch.retry": "Search again",
"unifiedSearch.resultCount": "{{count}} results",
"unifiedSearch.resultsLabel": "Unified search results",
"unifiedSearch.sourceRecord": "Record",
"unifiedSearch.sourceMeeting": "Meeting",
"unifiedSearch.untitledRecord": "Untitled record",
"unifiedSearch.untitledMeeting": "Untitled meeting",
"unifiedSearch.matchedTitle": "Title match",
"unifiedSearch.matchedContent": "Content match",
"unifiedSearch.matchedTranscript": "Transcript match",
"unifiedSearch.openResult": "Open {{source}} “{{title}}”",
"unifiedSearch.noPreview": "No preview available.",
"unifiedSearch.back": "Back to previous screen",
"unifiedSearch.signInRequired": "Sign in to use unified search."
}

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} funciones",
"mobile.works.searchPlaceholder": "Buscar funciones",
"mobile.works.pinned": "Fijado",
"mobile.works.pin": "Fijar arriba",
"mobile.works.unpin": "Quitar fijado",
"mobile.works.emptySearch": "No hay funciones que coincidan.",
"mobile.works.section.meetings": "Trabajo de reuniones",
"mobile.works.section.team": "Equipo y conocimiento",
"mobile.works.section.data": "Datos y avisos",
"mobile.works.historyDescription": "Busca y gestiona transcripciones de grabaciones e importaciones.",
"mobile.meetings.templateEmpty": "No hay plantillas de documento de reunión disponibles. Puedes iniciar la reunión sin una.",
"mobile.meetings.templateNone": "Empezar sin plantilla",
"mobile.meetings.templateNoneDesc": "Elige una plantilla al generar documentos",

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} fonctions",
"mobile.works.searchPlaceholder": "Rechercher une fonction",
"mobile.works.pinned": "Épinglé",
"mobile.works.pin": "Épingler",
"mobile.works.unpin": "Détacher",
"mobile.works.emptySearch": "Aucune fonction correspondante.",
"mobile.works.section.meetings": "Travail de réunion",
"mobile.works.section.team": "Équipe et savoir",
"mobile.works.section.data": "Données et alertes",
"mobile.works.historyDescription": "Recherchez et gérez les transcriptions des enregistrements et importations.",
"mobile.meetings.templateEmpty": "Aucun modèle de document de réunion disponible. Vous pouvez démarrer la réunion sans modèle.",
"mobile.meetings.templateNone": "Démarrer sans modèle",
"mobile.meetings.templateNoneDesc": "Choisissez un modèle lors de la génération de documents",

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "機能 {{count}}件",
"mobile.works.searchPlaceholder": "機能を検索",
"mobile.works.pinned": "ピン留め",
"mobile.works.pin": "ピン留め",
"mobile.works.unpin": "ピン留め解除",
"mobile.works.emptySearch": "一致する機能がありません。",
"mobile.works.section.meetings": "会議ワーク",
"mobile.works.section.team": "チーム・ナレッジ",
"mobile.works.section.data": "データ・通知",
"mobile.works.historyDescription": "録音とインポートの文字起こしを検索・管理します。",
"mobile.meetings.templateEmpty": "利用可能な会議ドキュメントテンプレートがありません。テンプレートなしで会議を開始できます。",
"mobile.meetings.templateNone": "テンプレートなしで開始",
"mobile.meetings.templateNoneDesc": "ドキュメント生成時にテンプレートを選択します",

View file

@ -483,17 +483,17 @@
"theme.catppuccin": "카푸치노",
"theme.dracula": "드라큘라",
"mobile.dash.title": "D3RO-VOICE",
"mobile.dash.sessionOverview": "SESSION OVERVIEW",
"mobile.dash.today": "TODAY",
"mobile.dash.words": "WORDS",
"mobile.dash.streak": "STREAK",
"mobile.dash.rec": "REC",
"mobile.dash.min": "MIN",
"mobile.dash.backend": "BACKEND",
"mobile.dash.tier": "TIER",
"mobile.dash.free": "FREE",
"mobile.dash.sessionOverview": "오늘의 기록",
"mobile.dash.today": "오늘",
"mobile.dash.words": "단어",
"mobile.dash.streak": "연속 사용",
"mobile.dash.rec": "녹음",
"mobile.dash.min": "분",
"mobile.dash.backend": "처리 방식",
"mobile.dash.tier": "요금제",
"mobile.dash.free": "무료",
"mobile.dash.tapToRecord": "REC 탭을 눌러 녹음 시작",
"mobile.dash.usage": "USAGE",
"mobile.dash.usage": "사용량",
"mobile.dash.dictation": "받아쓰기",
"mobile.dash.llmProcess": "LLM 처리",
"mobile.dash.premiumQuota": "프리미엄 쿼터",
@ -516,10 +516,10 @@
"mobile.dash.noHistory": "완료된 전사 기록이 없습니다. 녹음 또는 파일 가져오기를 시작하세요.",
"mobile.status.cloudModel": "클라우드 AI",
"mobile.status.secureDataLink": "보안 데이터 연결",
"mobile.hist.title": "HISTORY",
"mobile.hist.all": "ALL",
"mobile.hist.saved": "SAVED",
"mobile.hist.processing": "PROC",
"mobile.hist.title": "기록",
"mobile.hist.all": "전체",
"mobile.hist.saved": "저장됨",
"mobile.hist.processing": "처리 중",
"mobile.hist.empty": "아직 기록이 없습니다. 녹음을 시작해보세요!",
"mobile.hist.emptySearch": "조건에 맞는 기록이 없습니다.",
"mobile.hist.searchPlaceholder": "제목과 전사 내용 검색",
@ -555,6 +555,14 @@
"mobile.hist.metadata": "메타데이터",
"mobile.hist.mode": "모드",
"mobile.hist.status": "상태",
"mobile.hist.mode.dictation": "받아쓰기",
"mobile.hist.mode.translate": "번역",
"mobile.hist.mode.command": "명령",
"mobile.hist.mode.caption": "자막",
"mobile.hist.mode.fileTranscription": "파일 전사",
"mobile.hist.status.completed": "완료",
"mobile.hist.status.cancelled": "취소됨",
"mobile.hist.status.error": "오류",
"mobile.hist.language": "언어",
"mobile.hist.duration": "길이",
"mobile.hist.model": "모델",
@ -568,9 +576,24 @@
"mobile.hist.audioResume": "계속 재생",
"mobile.hist.audioStop": "정지",
"mobile.hist.audioPending": "업로드 상태: {{status}}",
"mobile.hist.audioStatus.pending": "대기 중",
"mobile.hist.audioStatus.uploading": "업로드 중",
"mobile.hist.audioStatus.uploaded": "업로드 완료",
"mobile.hist.audioStatus.failed": "업로드 실패",
"mobile.hist.audioStatus.deleted": "삭제됨",
"mobile.hist.audioError": "오디오를 안전하게 열지 못했습니다.",
"mobile.hist.jobs": "처리 작업",
"mobile.hist.jobsNone": "연결된 처리 작업이 없습니다.",
"mobile.hist.jobKind.transcription": "전사",
"mobile.hist.jobKind.summary": "요약",
"mobile.hist.jobKind.minutes": "회의록",
"mobile.hist.jobKind.diarization": "화자 분리",
"mobile.hist.jobKind.export": "내보내기",
"mobile.hist.jobStatus.queued": "대기 중",
"mobile.hist.jobStatus.running": "처리 중",
"mobile.hist.jobStatus.succeeded": "완료",
"mobile.hist.jobStatus.failed": "실패",
"mobile.hist.jobStatus.cancelled": "취소됨",
"mobile.hist.share": "공유",
"mobile.hist.shareFailed": "공유 시트를 열지 못했습니다.",
"mobile.hist.editFailed": "서버에서 편집을 확정하지 못했습니다.",
@ -579,7 +602,7 @@
"mobile.hist.savedSuccess": "서버에 저장했습니다.",
"mobile.hist.validationTitle": "제목은 160자 이하여야 합니다.",
"mobile.hist.validationOriginal": "원문은 비워둘 수 없습니다.",
"mobile.dictionary.title": "CUSTOM DICTIONARY",
"mobile.dictionary.title": "사용자 사전",
"mobile.dictionary.count": "{{count}}개 단어",
"mobile.dictionary.add": "단어 추가",
"mobile.dictionary.search": "단어 또는 발음 검색",
@ -614,11 +637,11 @@
"mobile.dictionary.error.notFound": "이 단어가 없거나 접근 권한이 없습니다.",
"mobile.dictionary.error.validation": "단어는 1~120자, 발음은 200자 이하로 입력하세요.",
"mobile.dictionary.error.server": "서버에서 사전 작업을 확정하지 못했습니다.",
"mobile.commands.title": "AI COMMANDS",
"mobile.commands.title": "AI 명령어",
"mobile.commands.count": "{{count}}개 서버 실행 프리셋",
"mobile.commands.readOnlyBadge": "READ ONLY",
"mobile.commands.readOnlyBadge": "읽기 전용",
"mobile.commands.contractNotice": "현재 서버에는 사용자 명령 저장 스키마가 없습니다. 이 화면은 변경 가능한 목록으로 위장하지 않으며, 검증된 기본 명령을 인증된 Cloud AI에 실제로 실행하는 테스트 벤치만 제공합니다. 녹음 후 자동 실행과는 아직 연결되지 않았습니다.",
"mobile.commands.available": "AVAILABLE SERVER COMMANDS",
"mobile.commands.available": "사용 가능한 서버 명령어",
"mobile.commands.translate.name": "영문 번역",
"mobile.commands.translate.description": "입력 텍스트를 자연스러운 영어로 번역하고 번역문만 반환합니다.",
"mobile.commands.summarize.name": "핵심 요약",
@ -627,14 +650,14 @@
"mobile.commands.formal.description": "원래 의미를 유지하며 격식 있는 비즈니스 문체로 다듬습니다.",
"mobile.commands.explain.name": "코드 설명",
"mobile.commands.explain.description": "코드의 역할과 주의점을 한국어로 설명합니다.",
"mobile.commands.testBench": "AUTHENTICATED TEST BENCH",
"mobile.commands.testBench": "인증된 명령 테스트",
"mobile.commands.selected": "선택: {{name}}",
"mobile.commands.inputPlaceholder": "서버 명령으로 처리할 실제 텍스트를 입력하세요.",
"mobile.commands.run": "서버에서 실행",
"mobile.commands.running": "실행 중...",
"mobile.commands.cancel": "취소",
"mobile.commands.serverProcessing": "인증 및 사용량 확인 후 Cloud AI가 처리 중입니다.",
"mobile.commands.serverResult": "SERVER RESULT",
"mobile.commands.serverResult": "서버 결과",
"mobile.commands.retry": "다시 실행",
"mobile.commands.error.auth": "로그인 세션이 만료됐습니다. 다시 로그인하세요.",
"mobile.commands.error.invalid": "입력 내용이 비어 있거나 허용 길이를 초과했습니다.",
@ -670,7 +693,7 @@
"mobile.commands.syncError.conflict": "다른 기기에서 먼저 수정했습니다. 최신 목록을 다시 불러왔습니다.",
"mobile.commands.syncError.notFound": "명령이 없거나 접근 권한이 없습니다.",
"mobile.commands.syncError.server": "서버에서 명령 변경을 확정하지 못했습니다.",
"mobile.knowledge.title": "KNOWLEDGE",
"mobile.knowledge.title": "지식",
"mobile.knowledge.description": "모바일 파일과 텍스트를 지식 문서로 동기화하고 실제 검색을 실행합니다.",
"mobile.knowledge.count": "{{count}}개 문서",
"mobile.knowledge.import": "텍스트 파일 가져오기",
@ -696,7 +719,7 @@
"mobile.knowledge.delete": "삭제",
"mobile.knowledge.deleting": "삭제 중",
"mobile.knowledge.retry": "다시 불러오기",
"mobile.knowledge.library": "DOCUMENT LIBRARY",
"mobile.knowledge.library": "문서 목록",
"mobile.knowledge.loading": "서버 지식 문서를 불러오는 중",
"mobile.knowledge.empty": "저장된 지식 문서가 없습니다. UTF-8 TXT 또는 Markdown 파일을 가져오세요.",
"mobile.knowledge.emptySearch": "제목 검색 조건에 맞는 문서가 없습니다.",
@ -705,9 +728,9 @@
"mobile.knowledge.teamShared": "팀에서 공유한 문서 · 소유자만 인덱싱하거나 삭제할 수 있습니다.",
"mobile.knowledge.indexing": "인덱싱 중",
"mobile.knowledge.retryIndex": "인덱스 재시도",
"mobile.knowledge.statusProcessing": "PROCESSING",
"mobile.knowledge.statusReady": "READY",
"mobile.knowledge.statusPending": "PENDING",
"mobile.knowledge.statusProcessing": "처리 중",
"mobile.knowledge.statusReady": "준비됨",
"mobile.knowledge.statusPending": "대기 중",
"mobile.knowledge.unknownDocument": "접근 가능한 문서",
"mobile.knowledge.error.auth": "지식 베이스를 사용하려면 다시 로그인해야 합니다.",
"mobile.knowledge.error.conflict": "다른 기기에서 문서가 먼저 변경되거나 삭제됐습니다. 최신 목록을 확인하세요.",
@ -721,10 +744,10 @@
"mobile.knowledge.error.validation": "1MB 이하의 유효한 UTF-8 TXT·Markdown 파일과 올바른 입력을 사용하세요.",
"mobile.knowledge.error.cancelled": "지식 작업을 취소했습니다.",
"mobile.knowledge.error.server": "지식 서버가 작업 완료를 확인하지 못했습니다.",
"mobile.actions.title": "ACTIONS",
"mobile.actions.title": "음성 액션",
"mobile.actions.description": "자연어 명령을 안전한 모바일 작업으로 분석하고 확인 후 실행합니다.",
"mobile.actions.subtitle": "확인 후 실행하는 모바일 안전 액션",
"mobile.actions.confirmBadge": "CONFIRM FIRST",
"mobile.actions.confirmBadge": "확인 후 실행",
"mobile.actions.contractNotice": "Cloud AI는 명령을 엄격한 JSON으로 파싱만 합니다. 회의 생성·지식 검색·회의 메모 저장만 모바일 허용 목록에 있으며 항상 사용자가 확인해야 실행됩니다. 팀 초대와 OS·외부 앱 부작용은 자동 실행하지 않습니다. 실행 기록은 현재 이 기기에 계정별로 저장되며 서버 감사 로그는 아직 없습니다.",
"mobile.actions.commandTitle": "자연어 명령",
"mobile.actions.inputPlaceholder": "실행하려는 작업을 구체적으로 입력하세요",
@ -734,8 +757,8 @@
"mobile.actions.serverParsing": "인증된 Cloud AI가 액션 계약을 분석하고 있습니다.",
"mobile.actions.executing": "실행 중",
"mobile.actions.reviewTitle": "실행 전 확인",
"mobile.actions.allowed": "ALLOWLISTED",
"mobile.actions.blocked": "BLOCKED",
"mobile.actions.allowed": "실행 가능",
"mobile.actions.blocked": "실행 차단",
"mobile.actions.type": "작업 유형",
"mobile.actions.arguments": "서버가 해석한 값",
"mobile.actions.rationale": "해석 근거",
@ -776,20 +799,20 @@
"mobile.actions.error.quota": "오늘 사용할 수 있는 Cloud AI 한도를 초과했습니다.",
"mobile.actions.error.timeout": "서버 응답 시간이 초과됐습니다. 실행 결과를 확인한 뒤 재시도하세요.",
"mobile.actions.error.server": "서버가 액션 완료를 확인하지 못했습니다.",
"mobile.rec.title": "RECORDER",
"mobile.rec.session": "REC_SESSION",
"mobile.rec.ready": "READY",
"mobile.rec.recording": "RECORDING",
"mobile.rec.processing": "PROCESSING",
"mobile.rec.done": "DONE",
"mobile.rec.error": "ERROR",
"mobile.rec.start": "START RECORDING",
"mobile.rec.stop": "STOP",
"mobile.rec.cancel": "CANCEL",
"mobile.rec.newRecording": "NEW RECORDING",
"mobile.rec.listening": "LISTENING_",
"mobile.rec.realtime": "REAL-TIME TRANSCRIPT",
"mobile.rec.initLog": "> initializing voice recording protocol...",
"mobile.rec.title": "녹음",
"mobile.rec.session": "녹음 중",
"mobile.rec.ready": "준비",
"mobile.rec.recording": "녹음 중",
"mobile.rec.processing": "처리 중",
"mobile.rec.done": "완료",
"mobile.rec.error": "오류",
"mobile.rec.start": "녹음 시작",
"mobile.rec.stop": "녹음 중지",
"mobile.rec.cancel": "취소",
"mobile.rec.newRecording": "새 녹음",
"mobile.rec.listening": "듣는 중",
"mobile.rec.realtime": "전사 내용",
"mobile.rec.initLog": "녹음을 준비하고 있습니다...",
"mobile.rec.micDenied": "마이크 권한이 거부되었습니다",
"mobile.rec.supabaseNotConfigured": "Supabase가 설정되지 않았습니다",
"mobile.rec.loginRequired": "로그인이 필요합니다",
@ -839,21 +862,21 @@
"mobile.rec.processRecovered": "복구 오디오 처리",
"mobile.rec.discardRecovered": "복구 오디오 삭제",
"mobile.rec.retryNow": "지금 다시 시도",
"mobile.talk.title": "TALK",
"mobile.talk.live": "LIVE",
"mobile.talk.you": "YOU",
"mobile.talk.claude": "CLAUDE",
"mobile.talk.title": "AI 대화",
"mobile.talk.live": "대화",
"mobile.talk.you": "나",
"mobile.talk.claude": "AI",
"mobile.talk.placeholder": "입력하거나 말하세요...",
"mobile.talk.greeting": "안녕하세요! 무엇을 도와드릴까요?",
"mobile.talk.cloudEngine": "보안 클라우드 AI",
"mobile.talk.clear": "지우기",
"mobile.talk.empty": "새 대화를 시작해.",
"mobile.talk.empty": "새 대화를 시작하세요.",
"mobile.talk.thinking": "답변을 생성하고 있습니다...",
"mobile.talk.transcribing": "기기 또는 보안 클라우드에서 음성을 전사하고 있습니다...",
"mobile.talk.recording": "녹음 중",
"mobile.talk.releaseToSend": "손을 떼면 전사 후 전송해",
"mobile.talk.releaseToSend": "손을 떼면 전사 후 전송합니다",
"mobile.talk.holdToTalk": "누르고 말하기",
"mobile.talk.pttHint": "마이크를 누른 채 말하고, 손을 떼면 전송해.",
"mobile.talk.pttHint": "마이크를 누른 채 말하고, 손을 떼면 전송합니다.",
"mobile.talk.cancel": "취소",
"mobile.talk.playSpeech": "이 답변 음성으로 재생",
"mobile.talk.stopSpeech": "음성 재생 중지",
@ -865,21 +888,21 @@
"mobile.talk.send": "메시지 보내기",
"mobile.talk.retry": "다시 시도",
"mobile.talk.error.auth": "세션이 만료되었습니다. 다시 로그인해 주세요.",
"mobile.talk.error.invalid": "보낼 수 없는 메시지야. 내용을 줄여서 다시 시도해.",
"mobile.talk.error.invalid": "보낼 수 없는 메시지입니다. 내용을 줄여서 다시 시도하세요.",
"mobile.talk.error.quota": "오늘 사용할 수 있는 AI 대화 한도를 모두 사용했습니다.",
"mobile.talk.error.model": "현재 요금제에서 선택한 AI 모델을 사용할 수 없습니다.",
"mobile.talk.error.provider": "AI 공급자가 설정되지 않았거나 잠시 사용할 수 없습니다.",
"mobile.talk.error.timeout": "AI 응답 시간이 초과되었습니다.",
"mobile.talk.error.network": "네트워크 연결을 확인하고 다시 시도해.",
"mobile.talk.error.network": "네트워크 연결을 확인하고 다시 시도하세요.",
"mobile.talk.error.noSpeech": "음성이 인식되지 않았습니다. 마이크에 가까이에서 다시 말씀해 주세요.",
"mobile.talk.error.response": "AI가 유효한 답변을 반환하지 않았습니다.",
"mobile.talk.error.cancelled": "요청이 취소되었습니다.",
"mobile.talk.error.micPermission": "눌러서 말하려면 마이크 권한이 필요해.",
"mobile.talk.error.micPermission": "눌러서 말하려면 마이크 권한이 필요합니다.",
"mobile.talk.error.recording": "마이크 녹음을 완료하지 못했습니다.",
"mobile.talk.error.tooShort": "마이크를 조금 더 오래 누른 뒤 손을 떼 주세요.",
"mobile.talk.error.sttQuota": "오늘 사용할 수 있는 음성 전사 한도를 모두 사용했습니다.",
"mobile.talk.error.stt": "보안 클라우드와 기기 음성 인식이 모두 실패했습니다.",
"mobile.talk.error.ttsUnavailable": "안드로이드 음성 엔진이 없거나 이 언어를 지원하지 않아.",
"mobile.talk.error.ttsUnavailable": "안드로이드 음성 엔진이 없거나 이 언어를 지원하지 않습니다.",
"mobile.talk.error.tts": "안드로이드가 이 답변을 음성으로 재생하지 못했습니다.",
"mobile.talk.debugAudioE2e": "실제 오디오 E2E 실행",
"mobile.talk.error.server": "AI 요청을 처리하지 못했습니다.",
@ -915,17 +938,17 @@
"mobile.report.error.network": "신고를 전송하지 못했습니다. 연결을 확인하고 다시 시도해 주세요.",
"mobile.report.error.server": "서버가 신고 접수를 확인하지 못했습니다. 다시 시도해 주세요.",
"mobile.set.title": "설정",
"mobile.set.accountPlan": "ACCOUNT & PLAN",
"mobile.set.backendConfig": "BACKEND CONFIG",
"mobile.set.preferences": "PREFERENCES",
"mobile.set.llmModel": "LLM Model",
"mobile.set.llmDesc": "AI processing backend",
"mobile.set.cloudStt": "Cloud STT",
"mobile.set.cloudSttDesc": "Google Cloud Speech",
"mobile.set.language": "Language",
"mobile.set.autoPolish": "Auto Polish",
"mobile.set.haptic": "Haptic Feedback",
"mobile.set.logout": "LOGOUT",
"mobile.set.accountPlan": "계정과 요금제",
"mobile.set.backendConfig": "AI 설정",
"mobile.set.preferences": "환경설정",
"mobile.set.llmModel": "AI 모델",
"mobile.set.llmDesc": "AI 처리에 사용할 모델",
"mobile.set.cloudStt": "클라우드 음성 인식",
"mobile.set.cloudSttDesc": "Google Cloud 음성 인식",
"mobile.set.language": "언어",
"mobile.set.autoPolish": "자동 다듬기",
"mobile.set.haptic": "진동 피드백",
"mobile.set.logout": "로그아웃",
"mobile.set.logoutConfirm": "정말 로그아웃 하시겠습니까?",
"mobile.set.loggingOut": "로그아웃 중...",
"mobile.set.openAccount": "계정 정보 열기",
@ -1053,13 +1076,13 @@
"mobile.onboarding.completeHint": "선택한 설정을 저장하고 계정 화면으로 이동합니다.",
"mobile.onboarding.skipHint": "튜토리얼을 완료로 표시하고 로그인 화면으로 이동합니다. 설정에서 다시 볼 수 있습니다.",
"mobile.login.title": "D3RO-VOICE",
"mobile.login.subtitle": "PRECISION VOICE INTELLIGENCE",
"mobile.login.subtitle": "정확한 음성 기록",
"mobile.login.google": "Google로 로그인",
"mobile.login.apple": "Apple로 로그인",
"mobile.login.github": "GitHub로 로그인",
"mobile.login.or": "또는",
"mobile.login.email": "EMAIL",
"mobile.login.password": "PASSWORD",
"mobile.login.email": "이메일",
"mobile.login.password": "비밀번호",
"mobile.login.signIn": "로그인",
"mobile.login.noAccount": "계정이 없으신가요?",
"mobile.login.forgotPassword": "비밀번호를 잊으셨나요?",
@ -1081,7 +1104,7 @@
"mobile.auth.localDataCleanupTitle": "기기 데이터 보호가 필요합니다",
"mobile.auth.localDataCleanupBody": "이전 계정의 로컬 데이터를 완전히 삭제하지 못해 로그인과 앱 사용을 차단했습니다. 다시 시도해주세요.",
"mobile.auth.localDataCleanupRetry": "보호 데이터 정리 다시 시도",
"mobile.signup.title": "CREATE ACCOUNT",
"mobile.signup.title": "계정 만들기",
"mobile.signup.subtitle": "Google 또는 이메일로 D3RO-VOICE 계정을 만드세요",
"mobile.signup.google": "Google로 가입",
"mobile.signup.googleLoading": "Google 연결 중...",
@ -1107,13 +1130,13 @@
"mobile.signup.created": "계정이 생성되어 로그인되었습니다.",
"mobile.signup.existing": "이미 가입된 이메일입니다. 로그인하거나 비밀번호 찾기를 이용해주세요.",
"mobile.signup.invalidResponse": "인증 서버에서 유효한 사용자 정보를 반환하지 않았습니다.",
"mobile.forgot.title": "RESET PASSWORD",
"mobile.forgot.title": "비밀번호 재설정",
"mobile.forgot.subtitle": "비밀번호 재설정 링크를 받으세요",
"mobile.forgot.send": "재설정 메일 보내기",
"mobile.forgot.sentTitle": "메일을 확인해주세요",
"mobile.forgot.sentBody": "계정이 존재하면 {{email}}로 비밀번호 재설정 링크가 전송됩니다.",
"mobile.forgot.resend": "재설정 메일 다시 보내기",
"mobile.updatePassword.title": "NEW PASSWORD",
"mobile.updatePassword.title": "새 비밀번호",
"mobile.updatePassword.subtitle": "계정에 사용할 새 비밀번호를 설정하세요",
"mobile.updatePassword.newPassword": "새 비밀번호",
"mobile.updatePassword.confirmPassword": "새 비밀번호 확인",
@ -1123,9 +1146,9 @@
"mobile.updatePassword.completed": "비밀번호가 변경되었습니다",
"mobile.updatePassword.completedBody": "새 비밀번호를 바로 사용할 수 있습니다.",
"mobile.updatePassword.continue": "계속",
"mobile.account.title": "ACCOUNT",
"mobile.account.profile": "PROFILE",
"mobile.account.accountInfo": "ACCOUNT INFO",
"mobile.account.title": "계정",
"mobile.account.profile": "프로필",
"mobile.account.accountInfo": "계정 정보",
"mobile.account.displayName": "표시 이름",
"mobile.account.avatarUrl": "아바타 URL",
"mobile.account.avatarPlaceholder": "https://example.com/avatar.png",
@ -1141,10 +1164,11 @@
"mobile.account.joinedAt": "가입일",
"mobile.account.lastLogin": "최근 로그인",
"mobile.account.tier": "구독 등급",
"mobile.account.tierUnknown": "알 수 없음",
"mobile.account.changePassword": "비밀번호 변경",
"mobile.account.logout": "로그아웃",
"mobile.account.logoutConfirm": "이 기기에서 로그아웃할까요?",
"mobile.account.dangerZone": "DANGER ZONE",
"mobile.account.dangerZone": "주의가 필요한 작업",
"mobile.account.deleteWarning": "계정을 삭제하면 클라우드 데이터가 영구 삭제되며 되돌릴 수 없습니다. 이용 중인 Google Play 구독은 자동 취소되지 않으므로 Google Play에서 별도로 취소해야 합니다.",
"mobile.account.deleteAcknowledge": "삭제의 영향을 이해했습니다.",
"mobile.account.delete": "계정 삭제",
@ -1536,6 +1560,11 @@
"mobile.templates.deleteBody": "‘{{name}}’ 템플릿을 삭제할까요? 선택 중인 템플릿이면 안전한 기본 템플릿으로 돌아갑니다.",
"mobile.templates.fieldCount": "입력 필드 {{count}}개",
"mobile.templates.documentType": "문서 유형 · {{type}}",
"mobile.templates.type.minutes": "회의록",
"mobile.templates.type.report": "보고서",
"mobile.templates.type.ideaNote": "아이디어 노트",
"mobile.templates.type.custom": "사용자 지정",
"mobile.templates.type.mindmap": "마인드맵",
"mobile.templates.loading": "클라우드 템플릿을 불러오는 중",
"mobile.templates.saving": "저장 중",
"mobile.templates.empty": "이 유형의 템플릿이 없습니다.",
@ -1577,6 +1606,10 @@
"mobile.memos.error.notFound": "히스토리 또는 태그를 찾을 수 없습니다.",
"mobile.memos.error.validation": "태그는 공백을 제외하고 1~80자로 입력하세요.",
"mobile.memos.error.server": "메모 태그 작업 또는 공유를 완료하지 못했습니다.",
"mobile.ads.sponsored": "광고",
"mobile.ads.unavailable": "광고를 불러올 수 없습니다",
"mobile.ads.loading": "광고 불러오는 중",
"mobile.ads.consent": "광고 동의 확인 중",
"mobile.admin.title": "모바일 관리자",
"mobile.admin.description": "현재 서버 역할: {{role}} · 모든 조회와 변경 권한을 서버에서 다시 확인합니다.",
"mobile.admin.forbiddenTitle": "관리자 권한이 없습니다",
@ -1647,17 +1680,6 @@
"mobile.admin.error.timeout": "서버 응답 시간이 초과됐습니다. 같은 멱등 키로 다시 시도할 수 있습니다.",
"mobile.admin.error.unavailable": "이 관리자 기능의 안전한 서버 계약이 아직 배포되지 않았습니다.",
"mobile.admin.error.server": "서버 응답을 안전하게 검증하지 못해 작업을 중단했습니다.",
"mobile.works.title": "WORKS",
"mobile.works.count": "기능 {{count}}개",
"mobile.works.searchPlaceholder": "기능 검색",
"mobile.works.pinned": "고정",
"mobile.works.pin": "고정",
"mobile.works.unpin": "고정 해제",
"mobile.works.emptySearch": "조건에 맞는 기능이 없습니다.",
"mobile.works.section.meetings": "회의 작업",
"mobile.works.section.team": "팀 · 지식",
"mobile.works.section.data": "데이터 · 알림",
"mobile.works.historyDescription": "녹음과 가져오기의 전사 기록을 검색하고 관리합니다.",
"mobile.meetings.templateNone": "템플릿 없이 시작",
"mobile.meetings.templateNoneDesc": "문서 생성 시점에 템플릿을 선택합니다",
"keybinding.mouse.left": "마우스 왼쪽 버튼",
@ -2554,5 +2576,98 @@
"tools.conversation.clearTitle": "대화 기록을 지울까요?",
"tools.conversation.clearBody": "메시지 {{count}}개가 모두 지워지며 되돌릴 수 없습니다.",
"tools.conversation.clearConfirm": "지우기",
"tools.conversation.clearFailed": "대화 기록을 지우지 못했습니다. 다시 시도하세요."
"tools.conversation.clearFailed": "대화 기록을 지우지 못했습니다. 다시 시도하세요.",
"mobile.nav.home": "홈",
"mobile.nav.records": "기록",
"mobile.nav.meetings": "회의",
"mobile.nav.more": "더보기",
"mobile.fab.open": "녹음 시작 메뉴 열기",
"mobile.fab.close": "녹음 시작 메뉴 닫기",
"mobile.fab.dictation": "받아쓰기 녹음",
"mobile.fab.dictationHint": "녹음 화면을 열어 받아쓰기를 시작합니다.",
"mobile.fab.meeting": "회의 녹음",
"mobile.fab.meetingHint": "새 회의를 만든 뒤 회의 화면에서 녹음합니다.",
"mobile.fab.import": "오디오 파일 가져오기",
"mobile.fab.importHint": "기기에 있는 오디오 파일을 골라 전사합니다.",
"mobile.home.title": "오늘",
"mobile.home.recordHint": "오른쪽 아래 녹음 버튼을 누르면 받아쓰기·회의 녹음·파일 가져오기를 시작할 수 있습니다.",
"mobile.home.summarySessions": "오늘 {{count}}회",
"mobile.home.summaryWords": "{{count}}단어",
"mobile.home.summaryMinutes": "녹음 {{minutes}}분",
"mobile.home.summaryStreak": "연속 {{count}}일",
"mobile.home.summaryNone": "오늘은 아직 기록이 없습니다",
"mobile.home.loading": "오늘 요약을 불러오고 있습니다",
"mobile.home.loadError": "오늘 요약을 불러오지 못했습니다.",
"mobile.home.retry": "다시 시도",
"mobile.home.recentRecords": "최근 기록",
"mobile.home.recentMeetings": "최근 회의",
"mobile.home.seeAll": "모두 보기",
"mobile.home.seeAllRecords": "기록 모두 보기",
"mobile.home.seeAllMeetings": "회의 모두 보기",
"mobile.home.noRecords": "아직 기록이 없습니다. 녹음 버튼으로 첫 기록을 남겨 보세요.",
"mobile.home.noMeetings": "아직 회의가 없습니다.",
"mobile.home.meetingsLoadError": "최근 회의를 불러오지 못했습니다.",
"mobile.home.freePlan": "무료 요금제를 쓰고 있습니다 · 요금제 보기",
"mobile.home.freePlanHint": "요금제와 리워드 화면으로 이동합니다.",
"mobile.more.title": "더보기",
"mobile.more.section.tools": "도구",
"mobile.more.section.account": "계정",
"mobile.more.item.talk": "AI 대화",
"mobile.more.item.talkDescription": "말이나 글로 AI와 대화합니다.",
"mobile.more.item.commands": "명령어",
"mobile.more.item.commandsDescription": "자주 쓰는 AI 명령을 만들고 실행합니다.",
"mobile.more.item.dictionary": "사전",
"mobile.more.item.dictionaryDescription": "고유명사와 전문 용어를 잘 알아듣도록 등록합니다.",
"mobile.more.item.knowledge": "지식",
"mobile.more.item.knowledgeDescription": "파일과 글을 지식 문서로 모아 검색합니다.",
"mobile.more.item.templates": "템플릿",
"mobile.more.item.templatesDescription": "받아쓰기와 회의 문서의 형식을 관리합니다.",
"mobile.more.item.tags": "기록 태그",
"mobile.more.item.tagsDescription": "기록에 태그를 달고 태그로 찾습니다.",
"mobile.more.item.teams": "팀",
"mobile.more.item.teamsDescription": "팀원 초대와 공유 회의를 관리합니다.",
"mobile.more.item.actions": "음성 액션",
"mobile.more.item.actionsDescription": "말로 한 요청을 확인한 뒤 작업으로 실행합니다.",
"mobile.more.item.notifications": "알림",
"mobile.more.item.notificationsDescription": "알림 권한과 이 기기의 알림 등록을 관리합니다.",
"mobile.more.item.portability": "데이터 가져오기·내보내기",
"mobile.more.item.portabilityDescription": "계정 데이터를 내려받거나 복원합니다.",
"mobile.more.item.account": "계정",
"mobile.more.item.accountDescription": "프로필, 로그인 방법, 비밀번호를 관리합니다.",
"mobile.more.item.plan": "요금제",
"mobile.more.item.planDescription": "현재 {{tier}} · 요금제와 리워드를 확인합니다.",
"mobile.more.item.devices": "기기",
"mobile.more.item.devicesDescription": "이 계정에 연결된 기기를 확인하고 해제합니다.",
"mobile.more.item.settings": "설정",
"mobile.more.item.settingsDescription": "화면, 언어, 녹음, 동기화를 설정합니다.",
"mobile.more.item.admin": "관리자",
"mobile.more.item.adminDescription": "사용자와 신고를 관리합니다.",
"mobile.more.tier.free": "무료",
"mobile.more.tier.pro": "Pro",
"mobile.more.tier.pro_plus": "Pro+",
"unifiedSearch.title": "통합 검색",
"unifiedSearch.homeHint": "기록과 회의 전사를 한 번에 찾습니다.",
"unifiedSearch.description": "기록 본문과 회의 제목·전사를 함께 찾습니다.",
"unifiedSearch.placeholder": "기록과 회의 검색",
"unifiedSearch.promptTitle": "찾을 내용을 입력하세요",
"unifiedSearch.promptDescription": "기록 본문, 회의 제목, 회의 전사에서 검색합니다.",
"unifiedSearch.noResultsTitle": "일치하는 결과가 없습니다",
"unifiedSearch.noResultsDescription": "‘{{query}}’와 일치하는 기록이나 회의를 찾지 못했습니다.",
"unifiedSearch.loading": "검색하는 중",
"unifiedSearch.loadErrorTitle": "검색하지 못했습니다",
"unifiedSearch.loadErrorDescription": "저장된 기록과 회의를 읽는 중 문제가 생겼습니다. 다시 시도해 주세요.",
"unifiedSearch.retry": "다시 검색",
"unifiedSearch.resultCount": "결과 {{count}}건",
"unifiedSearch.resultsLabel": "통합 검색 결과",
"unifiedSearch.sourceRecord": "기록",
"unifiedSearch.sourceMeeting": "회의",
"unifiedSearch.untitledRecord": "제목 없는 기록",
"unifiedSearch.untitledMeeting": "제목 없는 회의",
"unifiedSearch.matchedTitle": "제목 일치",
"unifiedSearch.matchedContent": "본문 일치",
"unifiedSearch.matchedTranscript": "전사 일치",
"unifiedSearch.openResult": "{{source}} ‘{{title}}’ 열기",
"unifiedSearch.noPreview": "미리 볼 내용이 없습니다.",
"unifiedSearch.back": "이전 화면으로",
"unifiedSearch.signInRequired": "통합 검색을 사용하려면 로그인해 주세요."
}

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} funções",
"mobile.works.searchPlaceholder": "Pesquisar funções",
"mobile.works.pinned": "Fixado",
"mobile.works.pin": "Fixar no topo",
"mobile.works.unpin": "Desafixar",
"mobile.works.emptySearch": "Nenhuma função correspondente.",
"mobile.works.section.meetings": "Trabalho de reuniões",
"mobile.works.section.team": "Equipe e conhecimento",
"mobile.works.section.data": "Dados e alertas",
"mobile.works.historyDescription": "Pesquise e gerencie transcrições de gravações e importações.",
"mobile.meetings.templateEmpty": "Nenhuma template de documento de reunião disponível. Você pode iniciar a reunião sem uma.",
"mobile.meetings.templateNone": "Começar sem template",
"mobile.meetings.templateNoneDesc": "Escolha uma template ao gerar documentos",

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} функций",
"mobile.works.searchPlaceholder": "Поиск функций",
"mobile.works.pinned": "Закреплено",
"mobile.works.pin": "Закрепить",
"mobile.works.unpin": "Открепить",
"mobile.works.emptySearch": "Подходящих функций нет.",
"mobile.works.section.meetings": "Работа с встречами",
"mobile.works.section.team": "Команда и знания",
"mobile.works.section.data": "Данные и оповещения",
"mobile.works.historyDescription": "Поиск и управление расшифровками записей и импорта.",
"mobile.meetings.templateEmpty": "Нет доступных шаблонов документов встреч. Можно начать встречу без шаблона.",
"mobile.meetings.templateNone": "Начать без шаблона",
"mobile.meetings.templateNoneDesc": "Шаблон можно выбрать при создании документов",

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} ฟีเจอร์",
"mobile.works.searchPlaceholder": "ค้นหาฟีเจอร์",
"mobile.works.pinned": "ปักหมุดแล้ว",
"mobile.works.pin": "ปักหมุดไว้ด้านบน",
"mobile.works.unpin": "ยกเลิกปักหมุด",
"mobile.works.emptySearch": "ไม่พบฟีเจอร์ที่ตรงกัน",
"mobile.works.section.meetings": "งานประชุม",
"mobile.works.section.team": "ทีมและความรู้",
"mobile.works.section.data": "ข้อมูลและการแจ้งเตือน",
"mobile.works.historyDescription": "ค้นหาและจัดการบทถอดความจากการบันทึกเสียงและการนำเข้า",
"mobile.meetings.templateEmpty": "ไม่มีเทมเพลตเอกสารประชุมที่ใช้ได้ เริ่มประชุมโดยไม่ใช้เทมเพลตได้",
"mobile.meetings.templateNone": "เริ่มโดยไม่ใช้เทมเพลต",
"mobile.meetings.templateNoneDesc": "เลือกเทมเพลตภายหลังเมื่อสร้างเอกสาร",

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} tính năng",
"mobile.works.searchPlaceholder": "Tìm tính năng",
"mobile.works.pinned": "Đã ghim",
"mobile.works.pin": "Ghim lên đầu",
"mobile.works.unpin": "Bỏ ghim",
"mobile.works.emptySearch": "Không có tính năng phù hợp.",
"mobile.works.section.meetings": "Công việc họp",
"mobile.works.section.team": "Nhóm & kiến thức",
"mobile.works.section.data": "Dữ liệu & thông báo",
"mobile.works.historyDescription": "Tìm kiếm và quản lý bản chép lời từ bản ghi và tệp nhập.",
"mobile.meetings.templateEmpty": "Không có mẫu tài liệu họp nào. Bạn có thể bắt đầu cuộc họp mà không cần mẫu.",
"mobile.meetings.templateNone": "Bắt đầu không cần mẫu",
"mobile.meetings.templateNoneDesc": "Chọn mẫu sau khi khi tạo tài liệu",

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} 個功能",
"mobile.works.searchPlaceholder": "搜尋功能",
"mobile.works.pinned": "已釘選",
"mobile.works.pin": "釘選",
"mobile.works.unpin": "取消釘選",
"mobile.works.emptySearch": "沒有符合的功能。",
"mobile.works.section.meetings": "會議工作",
"mobile.works.section.team": "團隊與知識",
"mobile.works.section.data": "資料與通知",
"mobile.works.historyDescription": "搜尋和管理錄音與匯入的逐字稿。",
"mobile.meetings.templateEmpty": "沒有可用的會議文件範本。可以不使用範本開始會議。",
"mobile.meetings.templateNone": "不使用範本開始",
"mobile.meetings.templateNoneDesc": "稍後產生文件時再選擇範本",

View file

@ -257,17 +257,6 @@
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"mobile.works.title": "WORKS",
"mobile.works.count": "{{count}} 个功能",
"mobile.works.searchPlaceholder": "搜索功能",
"mobile.works.pinned": "已固定",
"mobile.works.pin": "置顶",
"mobile.works.unpin": "取消置顶",
"mobile.works.emptySearch": "没有匹配的功能。",
"mobile.works.section.meetings": "会议工作",
"mobile.works.section.team": "团队与知识",
"mobile.works.section.data": "数据与通知",
"mobile.works.historyDescription": "搜索和管理录音与导入的转写记录。",
"mobile.meetings.templateEmpty": "没有可用的会议文档模板。可以不使用模板开始会议。",
"mobile.meetings.templateNone": "不使用模板开始",
"mobile.meetings.templateNoneDesc": "稍后生成文档时再选择模板",

View file

@ -31,9 +31,9 @@ export function FilterChip({
minHeight: 48,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: active ? palette.accent.main : palette.bg.card,
backgroundColor: active ? palette.accent.solid : palette.bg.card,
borderWidth: 1,
borderColor: active ? palette.accent.main : palette.border.default,
borderColor: active ? palette.accent.solid : palette.border.default,
borderRadius: d3roNativeRadius.pill,
paddingHorizontal: 16,
paddingVertical: 6
@ -43,8 +43,8 @@ export function FilterChip({
>
<PhosphorText
variant="small"
color={active ? 'primary' : 'muted'}
style={active ? { color: palette.text.onAccent ?? palette.bg.app } : undefined}
color={active ? 'primary' : 'tertiary'}
style={active ? { color: palette.accent.ink } : undefined}
>
{label}
</PhosphorText>

View file

@ -1,60 +0,0 @@
// packages/ui-native/src/components/Header.tsx
// RN Header — D3RO styled screen header with LEDs
// safe-area insets handled by caller (paddingTop prop or wrapper)
import { View, type ViewStyle, type StyleProp } from 'react-native'
import { Led } from './Led'
import { PhosphorText } from './PhosphorText'
import { useNativePalette } from '../theme-context'
export interface HeaderProps {
title: string
showBorder?: boolean
rightContent?: React.ReactNode
paddingTop?: number
style?: StyleProp<ViewStyle>
}
export function Header({
title,
showBorder = true,
rightContent,
paddingTop = 0,
style
}: HeaderProps): React.ReactElement {
const palette = useNativePalette()
return (
<View
style={[
{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: paddingTop + 8,
paddingBottom: 12,
borderBottomWidth: showBorder ? 1 : 0,
borderBottomColor: palette.border.subtle
},
style
]}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Led color="amber" size={8} />
<PhosphorText
accessibilityRole="header"
variant="label"
color="muted"
>
{title}
</PhosphorText>
</View>
{rightContent ?? (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Led color="green" size={6} />
<Led color="amber" size={6} />
</View>
)}
</View>
)
}

View file

@ -1,35 +1,37 @@
// packages/ui-native/src/components/Led.tsx
// RN용 Led — 작은 발광 원
// v4: 상태 점. 번짐(글로우) 없음. 색만으로 상태를 말하지 않는다 — 옆에 글자를 둔다.
import { View, type ViewStyle } from 'react-native'
import { useNativePalette } from '../theme-context'
export interface LedProps {
color?: 'amber' | 'green' | 'red' | 'orange' | 'blue'
/** 'amber' 는 v1 호환 별칭 — 현재 테마의 액센트 */
color?: 'amber' | 'green' | 'red' | 'orange' | 'blue' | 'off'
on?: boolean
size?: number
}
export function Led({ color = 'amber', on = true, size = 8 }: LedProps): React.ReactElement {
export function Led({ color = 'off', on = true, size = 8 }: LedProps): React.ReactElement {
const palette = useNativePalette()
const colorMap: Record<NonNullable<LedProps['color']>, string> = {
amber: palette.accent.main,
green: palette.tag.green,
red: palette.tag.red,
orange: palette.tag.orange,
blue: palette.tag.blue
green: palette.status.success,
red: palette.status.danger,
orange: palette.status.warning,
blue: palette.status.info,
off: palette.led.off
}
const c = colorMap[color]
const style: ViewStyle = {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: on ? c : palette.led.off,
shadowColor: c,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: on ? 0.8 : 0,
shadowRadius: on ? 4 : 0,
elevation: on ? 2 : 0
backgroundColor: on ? colorMap[color] : palette.led.off
}
return <View style={style} />
return (
<View
style={style}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
/>
)
}

View file

@ -1,5 +1,6 @@
// packages/ui-native/src/components/MetalCard.tsx
// RN용 MetalCard — View + 섀시 스타일
// v4: 평평한 카드 — 표면 한 단 + 1px 선. 그림자·광택 없음. (데스크톱 MetalCard 와 같은 방식)
// inset: 카드 안의 한 단 들어간 영역 (카드 안의 카드가 아니라 우묵한 판).
import { View, type ViewProps, type ViewStyle, type StyleProp } from 'react-native'
import { d3roNativeRadius } from '../theme'
@ -15,16 +16,10 @@ export function MetalCard({ children, inset = false, style, ...rest }: MetalCard
const baseStyle: ViewStyle = {
backgroundColor: inset ? palette.bg.inset : palette.bg.card,
borderRadius: inset ? d3roNativeRadius.inner : d3roNativeRadius.card,
padding: inset ? 6 : 16,
borderWidth: inset ? 0 : 1,
borderColor: palette.border.subtle,
overflow: 'hidden',
// RN 그림자
shadowColor: palette.shadow,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: inset ? 0 : 0.25,
shadowRadius: inset ? 0 : 8,
elevation: inset ? 0 : 4
borderWidth: 1,
borderColor: inset ? palette.border.subtle : palette.border.default,
padding: inset ? 12 : 16,
overflow: 'hidden'
}
return (

View file

@ -1,42 +1,59 @@
// packages/ui-native/src/components/PhosphorText.tsx
// RN용 PhosphorText — v3: 시스템 산세리프 기반 (한글은 Apple SD Gothic Neo /
// Noto Sans KR 자동 폴백). 텍스트 글로우는 제거하고 명도로 위계를 만든다.
// RN 텍스트 — v4: 시스템 산세리프(한글은 Apple SD Gothic Neo / Noto Sans KR 자동 폴백).
// 글로우 없음, 대문자 변환 없음. 위계는 크기(variant)와 명도(color 3단)로 만든다.
// 기본 색은 모든 변형에서 text.primary — 큰 숫자·제목을 액센트로 칠하지 않는다.
import { Text, type TextProps, type TextStyle, type StyleProp } from 'react-native'
import { d3roNativeFonts, d3roNativeTypo, type D3roNativeTypoKey } from '../theme'
import { d3roNativeFonts, d3roNativeTypo, type D3roNativePalette, type D3roNativeTypoKey } from '../theme'
import { useNativePalette } from '../theme-context'
export type PhosphorVariant = D3roNativeTypoKey
type PhosphorColor =
| 'primary'
| 'secondary'
| 'tertiary'
| 'accent'
/** @deprecated v4: 'accent' 별칭 */
| 'amber'
/** @deprecated v4: 'tertiary' 별칭 */
| 'label'
/** @deprecated v4: 'tertiary' 별칭 */
| 'muted'
export interface PhosphorTextProps extends TextProps {
variant?: PhosphorVariant
color?: 'amber' | 'primary' | 'secondary' | 'label' | 'muted'
color?: PhosphorColor
style?: StyleProp<TextStyle>
}
function resolveColor(palette: D3roNativePalette, color: PhosphorColor): string {
switch (color) {
case 'accent':
case 'amber':
return palette.accent.light
case 'secondary':
return palette.text.secondary
case 'tertiary':
case 'label':
case 'muted':
return palette.text.tertiary
case 'primary':
return palette.text.primary
}
}
export function PhosphorText({
children,
variant = 'body',
color,
color = 'primary',
style,
...rest
}: PhosphorTextProps): React.ReactElement {
const palette = useNativePalette()
const typo = d3roNativeTypo[variant]
const isAmberVariant = variant === 'hero' || variant === 'title' || variant === 'value'
const resolvedColor: string = (() => {
if (color === 'amber') return palette.accent.main
if (color === 'primary') return palette.text.primary
if (color === 'secondary') return palette.text.secondary
if (color === 'label') return palette.text.label
if (color === 'muted') return palette.text.muted
return isAmberVariant ? palette.accent.main : palette.text.primary
})()
const baseStyle: TextStyle = {
...typo,
color: resolvedColor,
...d3roNativeTypo[variant],
color: resolveColor(palette, color),
fontFamily: d3roNativeFonts.sans
}

View file

@ -1,7 +1,8 @@
// packages/ui-native/src/components/PhysicalButton.tsx
// RN용 PhysicalButton — Pressable 기반 누르는 느낌
// v4 "조용한 계기": 버튼 한 종류, 톤 넷 — 데스크톱 PhysicalButton 과 같은 방식.
// accent = 채움(accent.solid + accent.ink) / glass = 1px 선 / danger = 선 + 위험색 글자 / ghost = 글자만.
// 채움은 accent 하나뿐. 눌림은 색 변화뿐 — 떠오르거나 가라앉지 않는다. 굵기 500.
import { useState } from 'react'
import {
Pressable,
Text,
@ -10,60 +11,60 @@ import {
type TextStyle,
type StyleProp
} from 'react-native'
import { d3roNativeRadius, d3roNativeTypo } from '../theme'
import { d3roNativeControl, d3roNativeFonts, d3roNativeRadius, d3roNativeTypo, type D3roNativePalette } from '../theme'
import { useNativePalette } from '../theme-context'
export type PhysicalButtonTone = 'accent' | 'glass' | 'danger' | 'ghost'
export interface PhysicalButtonProps extends Omit<PressableProps, 'children' | 'style'> {
label: string
/** 시각 톤. 지정하면 variant 보다 우선한다. */
tone?: PhysicalButtonTone
/** @deprecated v4: tone 을 쓴다. primary=accent, secondary=glass, danger=danger */
variant?: 'primary' | 'secondary' | 'danger'
disabled?: boolean
style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
}
interface ToneStyle {
bg: string
bgPressed: string
color: string
border: string
}
export function physicalButtonTone(palette: D3roNativePalette, tone: PhysicalButtonTone): ToneStyle {
switch (tone) {
case 'accent':
return { bg: palette.accent.solid, bgPressed: palette.accent.solidHover, color: palette.accent.ink, border: palette.accent.solid }
case 'danger':
return { bg: 'transparent', bgPressed: palette.status.dangerBg, color: palette.status.danger, border: palette.border.strong }
case 'ghost':
return { bg: 'transparent', bgPressed: palette.overlay.soft, color: palette.text.secondary, border: 'transparent' }
case 'glass':
return { bg: 'transparent', bgPressed: palette.overlay.soft, color: palette.text.primary, border: palette.border.strong }
}
}
const VARIANT_TONE: Record<NonNullable<PhysicalButtonProps['variant']>, PhysicalButtonTone> = {
primary: 'accent',
secondary: 'glass',
danger: 'danger'
}
export function PhysicalButton({
label,
tone,
variant = 'primary',
disabled = false,
style,
textStyle,
onPress,
...rest
}: PhysicalButtonProps): React.ReactElement {
const palette = useNativePalette()
const [pressed, setPressed] = useState(false)
const bgColor =
variant === 'primary'
? palette.accent.main
: variant === 'danger'
? palette.tag.red
: 'transparent'
const borderColor =
variant === 'secondary' ? palette.border.strong : 'transparent'
const textColor =
variant === 'primary' || variant === 'danger'
? palette.text.onAccent ?? palette.text.primary
: palette.text.secondary
const containerStyle: ViewStyle = {
backgroundColor: bgColor,
borderColor,
borderWidth: variant === 'secondary' ? 1 : 0,
borderRadius: d3roNativeRadius.button,
minHeight: 48,
paddingVertical: 14,
paddingHorizontal: 24,
alignItems: 'center',
justifyContent: 'center',
opacity: disabled ? 0.4 : pressed ? 0.75 : 1,
transform: [{ translateY: pressed && !disabled ? 1 : 0 }]
}
const textStyle: TextStyle = {
...d3roNativeTypo.heading,
color: textColor
}
const t = physicalButtonTone(palette, tone ?? VARIANT_TONE[variant])
return (
<Pressable
@ -74,12 +75,37 @@ export function PhysicalButton({
disabled,
}}
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
disabled={disabled}
style={[containerStyle, style]}
style={({ pressed }) => [
{
backgroundColor: pressed && !disabled ? t.bgPressed : t.bg,
borderColor: t.border,
borderWidth: 1,
borderRadius: d3roNativeRadius.button,
minHeight: d3roNativeControl.h,
paddingVertical: 12,
paddingHorizontal: d3roNativeControl.padX,
alignItems: 'center',
justifyContent: 'center',
opacity: disabled ? 0.5 : 1
},
style
]}
>
<Text style={textStyle}>{label}</Text>
<Text
style={[
{
...d3roNativeTypo.body,
fontWeight: '500',
fontFamily: d3roNativeFonts.sans,
color: t.color,
textAlign: 'center'
},
textStyle
]}
>
{label}
</Text>
</Pressable>
)
}

View file

@ -1,5 +1,5 @@
// packages/ui-native/src/components/ScreenPanel.tsx
// RN ScreenPanel — inset bg + dot grid background
// v4: 우묵한 판 — inset 표면 + 1px 선. (점 격자·CRT 장식 없음)
import { View, type ViewProps, type ViewStyle, type StyleProp } from 'react-native'
import { d3roNativeRadius } from '../theme'
@ -14,7 +14,9 @@ export function ScreenPanel({ children, style, ...rest }: ScreenPanelProps): Rea
const baseStyle: ViewStyle = {
backgroundColor: palette.bg.inset,
borderRadius: d3roNativeRadius.inner,
padding: 20,
borderWidth: 1,
borderColor: palette.border.subtle,
padding: 16,
overflow: 'hidden'
}

View file

@ -1,52 +0,0 @@
// packages/ui-native/src/components/StatusBar.tsx
// RN StatusBar — bottom status strip with LED + model label
import { View, type ViewStyle, type StyleProp } from 'react-native'
import { d3roNativeFonts } from '../theme'
import { useNativePalette } from '../theme-context'
import { Led } from './Led'
import { PhosphorText } from './PhosphorText'
export interface AppStatusBarProps {
model?: string
label?: string
style?: StyleProp<ViewStyle>
}
export function AppStatusBar({
model = 'CLOUD CLAUDE',
label = 'PRECISION DATA LINK',
style
}: AppStatusBarProps): React.ReactElement {
const palette = useNativePalette()
const containerStyle: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
paddingHorizontal: 16,
gap: 8,
opacity: 0.5
}
return (
<View style={[containerStyle, style]}>
<Led color="green" size={5} />
<PhosphorText
variant="label"
color="muted"
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 10, letterSpacing: 0.6 }}
>
{model}
</PhosphorText>
<View style={{ width: 1, height: 8, backgroundColor: palette.border.default }} />
<PhosphorText
variant="label"
color="muted"
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 10, letterSpacing: 0.6 }}
>
{label}
</PhosphorText>
</View>
)
}

View file

@ -3,11 +3,24 @@
export {
d3roNativePalette,
d3roNativeDarkPalette,
d3roNativeLightPalette,
getD3roNativePalette,
D3RO_NATIVE_RAW,
D3RO_NATIVE_STATUS,
d3roNativeTypo,
d3roNativeRadius,
d3roNativeFonts,
d3roNativeSpace,
d3roNativeControl,
d3roNativeWeight,
d3roNativeMotion,
d3roNativeNumerals,
d3roNativeOverlayShadow,
type D3roNativePalette,
type D3roNativePaletteKey,
type D3roNativeStatus,
type D3roNativeThemeKey,
type D3roNativeTypoKey
} from './theme'
export {
@ -25,6 +38,4 @@ export { Led, type LedProps } from './components/Led'
export { PhysicalButton, type PhysicalButtonProps } from './components/PhysicalButton'
export { ScreenPanel, type ScreenPanelProps } from './components/ScreenPanel'
export { WaveBars, type WaveBarsProps } from './components/WaveBars'
export { AppStatusBar, type AppStatusBarProps } from './components/StatusBar'
export { Header, type HeaderProps } from './components/Header'
export { FilterChip, type FilterChipProps } from './components/FilterChip'

View file

@ -1,8 +1,31 @@
// packages/ui-native/src/theme.ts
// RN용 테마 토큰 — MUI 없이 순수 색상/타이포/그림자 값
// packages/ui의 d3roPalette와 의도적으로 동기화 (RN은 CSS var 불가, 정적 값)
// RN 디자인 토큰 v4 "조용한 계기" — 모바일 팔레트의 단일 출처.
//
// 색 값은 데스크톱 SSOT `packages/ui/src/theme-raw.ts` 의 BASE.dark / BASE.light 와 STATUS 에서 옮겨 왔다.
// RN 은 CSS 변수를 못 쓰고, Metro 가 packages/ui 를 watchFolders 에 두지 않아 직접 import 하지 않는다.
// ⚠ theme-raw.ts 와 같은 값 — 바뀌면 함께 바꾼다. apps/mobile-rn/__tests__/theme-parity.test.ts 가 값이 같은지 검사한다.
//
// 원칙 (루트 design.md "2026-09-28 전면 개편"):
// - 표면은 평평하다. 위계는 크기·명도(텍스트 3단)·1px 선·여백으로 만든다. 글로우·그라디언트·장식 LED 없음.
// - 텍스트 3단(primary/secondary/tertiary)은 app/card/elevated/sidebar 표면에서 4.5:1 이상.
// 옛 label/inactive/muted 는 tertiary 의 호환 별칭이다.
// - accent.solid 는 채운 버튼 배경, accent.ink 는 그 위 글자 (4.5:1 이상). accent.main 은 채움에 쓰지 않는다.
// - 굵기는 400/500 (강조 600). 대문자 변환·자간 없음. 모노 폰트 없음(한글 글리프가 없다).
import { Platform } from 'react-native'
import { Platform, type TextStyle, type ViewStyle } from 'react-native'
export type D3roNativeThemeKey = 'dark' | 'light'
export interface D3roNativeStatus {
success: string
successBg: string
warning: string
warningBg: string
danger: string
dangerBg: string
info: string
infoBg: string
}
export interface D3roNativePalette {
bg: {
@ -10,26 +33,50 @@ export interface D3roNativePalette {
card: string
cardHover: string
elevated: string
input: string
sidebar: string
inset: string
/** @deprecated v4: bg.card 와 같다 */
chassis: string
}
text: {
primary: string
white: string
onAccent?: string
secondary: string
label: string
/** 가장 옅은 읽기용 글자 — 그래도 4.5:1 이상 */
tertiary: string
/** 대비 예외: 비활성 표시 전용 */
disabled: string
/** @deprecated v4: accent.ink 와 같다 (채운 버튼 위 글자) */
onAccent: string
/** 스위치 손잡이·체크 표시처럼 테마와 무관하게 흰 것 */
white: string
/** @deprecated v4: text.tertiary 별칭 */
label: string
/** @deprecated v4: text.tertiary 별칭 */
inactive: string
/** @deprecated v4: text.tertiary 별칭 */
muted: string
}
accent: {
/** 선·아이콘·선택 표시 */
main: string
pressed?: string
/** 표면 위 강조 글자 (라이트 테마에서는 더 진한 값) */
light: string
dark: string
/** 옅은 선택 배경 */
dim: string
/** 채운 버튼 배경 — ink 와 4.5:1 이상 */
solid: string
solidHover: string
/** solid 위 글자 */
ink: string
/** @deprecated v4: accent.solidHover 별칭 (눌림) */
pressed: string
/** @deprecated v4: 글로우 없음 — accent.dim 과 같다 */
glow: string
/** @deprecated v4: status.success 별칭 */
green: string
/** @deprecated v4: status.successBg 별칭 */
greenGlow: string
}
border: {
@ -37,6 +84,7 @@ export interface D3roNativePalette {
default: string
strong: string
}
/** 카테고리·역할 구분색. 글자로도 쓰이므로 테마마다 대비를 맞춘 값 (red/orange/green/blue = STATUS). */
tag: {
purple: string
orange: string
@ -44,125 +92,187 @@ export interface D3roNativePalette {
green: string
blue: string
}
/** v3: 시맨틱 상태 역할 (액센트와 분리) */
status: {
success: string
successBg: string
warning: string
warningBg: string
danger: string
dangerBg: string
info: string
infoBg: string
}
/** 시맨틱 상태 역할 (액센트와 분리) — theme-raw STATUS */
status: D3roNativeStatus
/** 모달 뒤 스크림 */
scrim: string
/** 표면 위 옅은 오버레이 (눌림·선택) */
overlay: { soft: string; strong: string }
led: { off: string }
shadow?: string
/** 떠 있는 층(시트·메뉴·플로팅 버튼) 그림자 색 — 표면에는 그림자를 쓰지 않는다 */
shadow: string
}
export const d3roNativePalette: D3roNativePalette = {
bg: {
app: '#19191b',
card: '#242427',
cardHover: '#2a2a2d',
elevated: '#2e2e32',
sidebar: '#1e1f21',
inset: '#0f0f11',
chassis: '#242528'
// ── theme-raw.ts 원시값 사본 ─────────────────────────────────────────
// theme-raw.ts 와 같은 값 — 바뀌면 함께. (BASE.dark / BASE.light 의 bg·text·border·accent, STATUS.dark / STATUS.light)
export const D3RO_NATIVE_RAW = {
dark: {
bg: { app: '#0a0e1c', card: '#111a30', cardHover: '#152039', elevated: '#1a2540', input: '#0d1526', sidebar: '#080b17', inset: '#0a111f' },
text: { primary: '#eef2fb', secondary: '#93a4c8', tertiary: '#7e8eb0', disabled: '#3c4763', hover: '#c9d5f2' },
border: { subtle: 'rgba(148,180,255,0.07)', default: 'rgba(148,180,255,0.12)', strong: 'rgba(148,180,255,0.20)' },
accent: { main: '#3b82f6', light: '#60a5fa', dark: '#2563eb', dim: 'rgba(59,130,246,0.14)', solid: '#2563eb', solidHover: '#1d4ed8', ink: '#ffffff' },
},
text: {
primary: '#d4d4d8',
white: '#ffffff',
secondary: '#8e8e93',
label: '#7c7c82',
disabled: '#4a4a4e',
inactive: '#77797c',
muted: '#71717a'
light: {
bg: { app: '#f2f5fb', card: '#ffffff', cardHover: '#f7f9fd', elevated: '#eef2f9', input: '#ffffff', sidebar: '#e9eef7', inset: '#e4e9f2' },
text: { primary: '#111827', secondary: '#4b5a75', tertiary: '#56647c', disabled: '#b5bfd1', hover: '#2b3a55' },
border: { subtle: 'rgba(30,58,138,0.07)', default: 'rgba(30,58,138,0.12)', strong: 'rgba(30,58,138,0.22)' },
accent: { main: '#2563eb', light: '#1d4ed8', dark: '#1e40af', dim: 'rgba(37,99,235,0.10)', solid: '#2563eb', solidHover: '#1d4ed8', ink: '#ffffff' },
},
accent: {
// 브랜드 통일: 앱(Midnight Glass) blue — 레거시 amber 폐기
main: '#3b82f6',
dim: 'rgba(59, 130, 246, 0.15)',
glow: 'rgba(59, 130, 246, 0.5)',
green: '#4ade80',
greenGlow: 'rgba(74, 222, 128, 0.6)'
},
border: {
subtle: 'rgba(255,255,255,0.04)',
default: '#2e2e32',
strong: 'rgba(255,255,255,0.12)'
},
tag: {
purple: '#b854f5',
orange: '#f59e0b',
red: '#ef4444',
green: '#22c55e',
blue: '#3b82f6'
},
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)'
},
led: {
off: '#111111'
},
shadow: '#000000'
} as const
// ── v3: 간격·컨트롤·웨이트·모션 (RN은 px 숫자) ──
export const D3RO_NATIVE_STATUS: Record<D3roNativeThemeKey, D3roNativeStatus> = {
dark: {
success: '#34d399', successBg: 'rgba(52,211,153,0.12)',
warning: '#fbbf24', warningBg: 'rgba(251,191,36,0.12)',
danger: '#f87171', dangerBg: 'rgba(248,113,113,0.12)',
info: '#60a5fa', infoBg: 'rgba(96,165,250,0.12)',
},
light: {
success: '#047857', successBg: 'rgba(4,120,87,0.10)',
warning: '#b45309', warningBg: 'rgba(180,83,9,0.10)',
danger: '#b91c1c', dangerBg: 'rgba(185,28,28,0.10)',
info: '#1d4ed8', infoBg: 'rgba(29,78,216,0.10)',
},
}
/** theme-raw 에 없는 모바일 전용 값. 보라만 글자 대비를 맞춘 역할색(violet 400/700)이다. */
const NATIVE_EXTRA: Record<D3roNativeThemeKey, {
purple: string
scrim: string
overlaySoft: string
overlayStrong: string
shadow: string
}> = {
dark: {
purple: '#a78bfa',
scrim: 'rgba(0,0,0,0.6)', // theme-raw --d3-scrim (dark)
overlaySoft: 'rgba(238,242,251,0.06)', // text.primary 6%
overlayStrong: 'rgba(238,242,251,0.12)',
shadow: '#030712',
},
light: {
purple: '#6d28d9',
scrim: 'rgba(17,24,39,0.32)', // theme-raw --d3-scrim (light)
overlaySoft: 'rgba(17,24,39,0.06)',
overlayStrong: 'rgba(17,24,39,0.12)',
shadow: '#1e3a8a',
},
}
function buildPalette(key: D3roNativeThemeKey): D3roNativePalette {
const raw = D3RO_NATIVE_RAW[key]
const status = D3RO_NATIVE_STATUS[key]
const extra = NATIVE_EXTRA[key]
return {
bg: { ...raw.bg, chassis: raw.bg.card },
text: {
primary: raw.text.primary,
secondary: raw.text.secondary,
tertiary: raw.text.tertiary,
disabled: raw.text.disabled,
onAccent: raw.accent.ink,
white: '#ffffff',
label: raw.text.tertiary,
inactive: raw.text.tertiary,
muted: raw.text.tertiary,
},
accent: {
...raw.accent,
pressed: raw.accent.solidHover,
glow: raw.accent.dim,
green: status.success,
greenGlow: status.successBg,
},
border: { ...raw.border },
tag: {
purple: extra.purple,
orange: status.warning,
red: status.danger,
green: status.success,
blue: status.info,
},
status: { ...status },
scrim: extra.scrim,
overlay: { soft: extra.overlaySoft, strong: extra.overlayStrong },
led: { off: raw.text.disabled },
shadow: extra.shadow,
}
}
export const d3roNativeDarkPalette: D3roNativePalette = buildPalette('dark')
export const d3roNativeLightPalette: D3roNativePalette = buildPalette('light')
/** 프로바이더 밖 기본값 (다크) */
export const d3roNativePalette: D3roNativePalette = d3roNativeDarkPalette
export function getD3roNativePalette(key: D3roNativeThemeKey): D3roNativePalette {
return key === 'light' ? d3roNativeLightPalette : d3roNativeDarkPalette
}
// ── 간격·컨트롤·웨이트·모션 (RN 은 숫자) ──
export const d3roNativeSpace = {
s1: 4, s2: 8, s3: 12, s4: 16, s5: 24, s6: 40, s7: 64, s8: 96
} as const
/** 터치 타깃: iOS 44pt / Android 48dp — 두 플랫폼 모두 48 로 맞춘다. 버튼처럼 생긴 것은 이 높이 이상. */
export const d3roNativeControl = {
h: 44, hSm: 36, padX: 16, padXSm: 10
h: 48,
padX: 16,
padXSm: 12
} as const
export const d3roNativeWeight = {
body: '400' as const, medium: '500' as const, strong: '600' as const
} as const
/** theme-raw MOTION 과 같은 길이(ms). 상태 변화 피드백만 — 등장 연출은 쓰지 않는다. */
export const d3roNativeMotion = {
instant: 100, quick: 200, normal: 350, slow: 600
instant: 100, quick: 160, normal: 240, slow: 400
} as const
// v3 "타이포그래피 퍼스트" — packages/ui d3roTypo v3와 동일 철학.
// 볼드(700) 폐기, 크기·행간·명도로 위계. 한글 행간 1.5~1.6,
// 자간 0 근처 (양수 트래킹은 라틴 대문자 라벨에만 소량).
// RN letterSpacing 단위는 px.
// v4 타입 — 데스크톱 d3roTypo v4 와 같은 철학: 크기·명도로 위계, 굵기 400/500, 자간 0(큰 제목만 살짝 음수).
// RN letterSpacing 단위는 px, lineHeight 는 px. 한글 본문 행간 1.6.
export const d3roNativeTypo = {
hero: { fontSize: 42, fontWeight: '400' as const, letterSpacing: -0.5, lineHeight: 50 },
title: { fontSize: 28, fontWeight: '400' as const, letterSpacing: -0.3, lineHeight: 36 },
hero: { fontSize: 28, fontWeight: '500' as const, letterSpacing: -0.4, lineHeight: 36 },
title: { fontSize: 22, fontWeight: '500' as const, letterSpacing: -0.2, lineHeight: 30 },
value: { fontSize: 20, fontWeight: '500' as const, letterSpacing: 0, lineHeight: 26 },
heading: { fontSize: 16, fontWeight: '500' as const, letterSpacing: 0, lineHeight: 24 },
body: { fontSize: 14, fontWeight: '400' as const, letterSpacing: 0, lineHeight: 22 },
small: { fontSize: 12, fontWeight: '400' as const, letterSpacing: 0, lineHeight: 18 },
meta: { fontSize: 11, fontWeight: '500' as const, letterSpacing: 0.3, lineHeight: 16 },
label: { fontSize: 11, fontWeight: '500' as const, letterSpacing: 0.4, lineHeight: 15 }
meta: { fontSize: 12, fontWeight: '400' as const, letterSpacing: 0, lineHeight: 18 },
label: { fontSize: 11, fontWeight: '500' as const, letterSpacing: 0, lineHeight: 16 }
} as const
/** theme-raw RADIUS 와 같은 값 — 정밀 도구: 작고 일정하게. */
export const d3roNativeRadius = {
card: 22,
inner: 12,
card: 10,
inner: 8,
button: 8,
small: 6,
xs: 4,
pill: 999
} as const
// 폰트 전략 (bare RN, 커스텀 폰트 번들링 없음):
// - sans: 플랫폼 시스템 산세리프. iOS=SF Pro(한글은 Apple SD Gothic Neo로 자동
// 폴백), Android=Roboto(한글은 Noto Sans KR로 자동 폴백). Apple HIG/Material
// 권장을 따르는 표준 방식이며 한글 렌더링 품질이 가장 안정적.
// - mono: 텔레메트리·코드 등 등폭이 필요한 곳에만 사용 (한글엔 사용 금지).
// 폰트: 플랫폼 시스템 산세리프 하나. iOS=SF Pro(한글 Apple SD Gothic Neo 자동 폴백),
// Android=Roboto(한글 Noto Sans KR 자동 폴백). 모노 폰트 토큰은 폐기(design.md v4) —
// 수치 정렬이 필요하면 fontVariant: ['tabular-nums'] 를 쓴다.
export const d3roNativeFonts = {
sans: Platform.OS === 'ios' ? 'System' : 'Roboto',
mono: Platform.OS === 'ios' ? 'Menlo' : 'monospace'
sans: Platform.OS === 'ios' ? 'System' : 'Roboto'
} as const
export type D3roNativePaletteKey = keyof typeof d3roNativePalette
/** 수치 정렬 — 흔들리는 숫자 방지 */
export const d3roNativeNumerals: TextStyle = { fontVariant: ['tabular-nums'] }
/** 떠 있는 층(시트·메뉴·플로팅 버튼) 전용 그림자. 카드·버튼 표면에는 쓰지 않는다. */
export function d3roNativeOverlayShadow(palette: D3roNativePalette): ViewStyle {
return {
shadowColor: palette.shadow,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.28,
shadowRadius: 16,
elevation: 8
}
}
export type D3roNativePaletteKey = keyof D3roNativePalette
export type D3roNativeTypoKey = keyof typeof d3roNativeTypo