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

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

View file

@ -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%' },