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
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:
parent
f505a03d34
commit
5ca3709b32
98 changed files with 5168 additions and 2093 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
apps/mobile-rn/.maestro/navigation-shell-search-local.yaml
Normal file
120
apps/mobile-rn/.maestro/navigation-shell-search-local.yaml
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
321
apps/mobile-rn/__tests__/navigation-shell.test.tsx
Normal file
321
apps/mobile-rn/__tests__/navigation-shell.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
48
apps/mobile-rn/__tests__/screen-header.test.tsx
Normal file
48
apps/mobile-rn/__tests__/screen-header.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
48
apps/mobile-rn/__tests__/theme-parity.test.ts
Normal file
48
apps/mobile-rn/__tests__/theme-parity.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
47
apps/mobile-rn/__tests__/unified-search-result-row.test.tsx
Normal file
47
apps/mobile-rn/__tests__/unified-search-result-row.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
215
apps/mobile-rn/__tests__/unified-search-service.test.ts
Normal file
215
apps/mobile-rn/__tests__/unified-search-service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 },
|
||||
})
|
||||
|
|
|
|||
348
apps/mobile-rn/src/components/RecordFab.tsx
Normal file
348
apps/mobile-rn/src/components/RecordFab.tsx
Normal 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' }],
|
||||
},
|
||||
})
|
||||
137
apps/mobile-rn/src/components/ScreenHeader.tsx
Normal file
137
apps/mobile-rn/src/components/ScreenHeader.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
92
apps/mobile-rn/src/components/UnifiedSearchResultRow.tsx
Normal file
92
apps/mobile-rn/src/components/UnifiedSearchResultRow.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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) : ''
|
||||
}
|
||||
272
apps/mobile-rn/src/features/search/unified-search-service.ts
Normal file
272
apps/mobile-rn/src/features/search/unified-search-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
})
|
||||
|
|
|
|||
22
apps/mobile-rn/src/navigation/tab-layout.ts
Normal file
22
apps/mobile-rn/src/navigation/tab-layout.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
399
apps/mobile-rn/src/screens/HomeScreen.tsx
Normal file
399
apps/mobile-rn/src/screens/HomeScreen.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
362
apps/mobile-rn/src/screens/MoreScreen.tsx
Normal file
362
apps/mobile-rn/src/screens/MoreScreen.tsx
Normal 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' }],
|
||||
},
|
||||
})
|
||||
|
|
@ -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%' },
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
276
apps/mobile-rn/src/screens/UnifiedSearchScreen.tsx
Normal file
276
apps/mobile-rn/src/screens/UnifiedSearchScreen.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue