Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"name": "MeteorVoice",
"slug": "meteorvoice-mobile",
"owner": "junchenmeteors-team",
"version": "1.4.0",
"version": "1.4.1",
"orientation": "portrait",
"scheme": "meteorvoice",
"userInterfaceStyle": "automatic",
"icon": "./assets/icon.png",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.jcmeteor.meteorvoice",
"buildNumber": "2026071101",
"buildNumber": "2026071102",
"deploymentTarget": "18.0",
"infoPlist": {
"NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.",
Expand All @@ -26,7 +26,7 @@
},
"android": {
"package": "com.jcmeteor.meteorvoice",
"versionCode": 2026071101,
"versionCode": 2026071102,
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
Expand Down
22 changes: 16 additions & 6 deletions apps/mobile/app/(tabs)/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@
* 历史标签页 — 会话历史与回顾界面。
*/

import { useMobileAuth } from '../../src/mobileAuth'
import {
useCallback,
useState,
} from 'react'
import { useFocusEffect } from 'expo-router'

import { HistoryScreen } from '../../src/screens/HistoryScreen'
import { useSession } from '../../src/SessionContext'

export default function HistoryTab() {
const { tr, locale, api } = useSession()
const auth = useMobileAuth()
const { tr, locale, api, auth, handleUnauthorized } = useSession()
const [focusVersion, setFocusVersion] = useState(0)

useFocusEffect(useCallback(() => {
setFocusVersion(value => value + 1)
}, []))

return (
<HistoryScreen
tr={tr}
locale={locale}
api={api}
getAuthHeaders={auth.getAuthHeaders}
handleUnauthorized={() => auth.signOut(null)}
defaultApiBaseUrl=""
authState={auth.state}
authUserId={auth.user?.id ?? null}
handleUnauthorized={handleUnauthorized}
refreshKey={focusVersion}
/>
)
}
8 changes: 4 additions & 4 deletions apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@
CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2026060601;
CURRENT_PROJECT_VERSION = 2026071102;
DEVELOPMENT_TEAM = VY69DPYH3T;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
Expand All @@ -351,7 +351,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.0;
MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand All @@ -376,15 +376,15 @@
CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2026060601;
CURRENT_PROJECT_VERSION = 2026071102;
DEVELOPMENT_TEAM = VY69DPYH3T;
INFOPLIST_FILE = MeteorVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.0;
MARKETING_VERSION = 1.4.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@meteorvoice/mobile",
"version": "1.4.0",
"version": "1.4.1",
"private": true,
"main": "index.ts",
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,8 @@ function AppInner({ children }: { children?: React.ReactNode }) {
// ─── Orchestration / 编排函数 ───

const synthesizeCoachSpeech = useCallback(async (text: string) => {
return api.synthesizeSpeech({ text, accent: accent.name, provider: ttsProvider, speed: ttsSpeedRouting.serverSpeed, voiceId: ttsVoiceId ?? undefined })
}, [accent.name, api, ttsProvider, ttsSpeedRouting.serverSpeed, ttsVoiceId])
return api.synthesizeSpeech({ text, accent: accent.name, provider: ttsProvider, speed: ttsSpeedRouting.requestSpeed, voiceId: ttsVoiceId ?? undefined })
}, [accent.name, api, ttsProvider, ttsSpeedRouting.requestSpeed, ttsVoiceId])

const submitTurn = useCallback(async (sourceTranscript: string) => {
const submitStartedAt = Date.now()
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export function AppShell({
<SessionContext.Provider value={sessionContext}>
{activeTab === 'home' && <HomeScreen tr={tr} locale={locale} scenarios={scenarios} onGoToSession={() => setActiveTab('session')} />}
{activeTab === 'session' && <SessionScreen tr={tr} accentName={accentName} accentRegion={accentRegion} scenarioName={scenarioName} scenarioIcon={scenarioIcon} scenarioDifficulty={scenarioDifficulty} scenarioDescription={scenarioDescription} />}
{activeTab === 'history' && <HistoryScreen tr={tr} locale={locale} api={api} getAuthHeaders={getAuthHeaders} handleUnauthorized={handleUnauthorized} defaultApiBaseUrl={defaultApiBaseUrl} />}
{activeTab === 'history' && <HistoryScreen tr={tr} locale={locale} api={api} authState={auth.state} authUserId={auth.user?.id ?? null} handleUnauthorized={handleUnauthorized} refreshKey={0} />}
{activeTab === 'settings' && <SettingsScreen tr={tr} locale={locale} appVersion={appVersion} defaultApiBaseUrl={defaultApiBaseUrl} auth={auth} signOut={signOut} handleUnauthorized={handleUnauthorized} getAuthHeaders={getAuthHeaders} onLocaleChange={setLocale} />}
</SessionContext.Provider>
<AppFeedbackOverlay feedback={activeFeedback} />
Expand Down
47 changes: 47 additions & 0 deletions apps/mobile/src/components/ContentState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useMemo } from 'react'
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native'

import { useTheme } from '../ThemeProvider'

interface Props {
actionLabel?: string
icon?: string
loading?: boolean
message?: string
onAction?: () => void
title: string
}

export function ContentState({ actionLabel, icon = '○', loading = false, message, onAction, title }: Props) {
const { C } = useTheme()
const styles = useMemo(() => StyleSheet.create({
action: { backgroundColor: C.surface, borderColor: C.border, borderRadius: 9, borderWidth: 1, marginTop: 6, paddingHorizontal: 14, paddingVertical: 9 },
actionText: { color: C.accent, fontSize: 13, fontWeight: '700' },
icon: { color: C.textMuted, fontSize: 28, fontWeight: '600' },
iconSurface: { alignItems: 'center', backgroundColor: C.surface, borderColor: C.border, borderRadius: 28, borderWidth: 1, height: 56, justifyContent: 'center', marginBottom: 10, width: 56 },
message: { color: C.textMuted, fontSize: 13, lineHeight: 19, maxWidth: 260, textAlign: 'center' },
root: { alignItems: 'center', flex: 1, justifyContent: 'center', paddingHorizontal: 24, paddingVertical: 40 },
title: { color: C.textSecondary, fontSize: 15, fontWeight: '700', marginBottom: 4, textAlign: 'center' },
}), [C])

return (
<View style={styles.root}>
<View style={styles.iconSurface}>
{loading ? <ActivityIndicator color={C.accent} /> : <Text style={styles.icon}>{icon}</Text>}
</View>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
{actionLabel && onAction ? (
<Pressable accessibilityRole="button" onPress={onAction} style={styles.action}>
<Text style={styles.actionText}>{actionLabel}</Text>
</Pressable>
) : null}
</View>
)
}
5 changes: 5 additions & 0 deletions apps/mobile/src/historyRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type HistoryAuthState = 'unconfigured' | 'loading' | 'signed-out' | 'signed-in' | 'error'

export function getHistoryLoadRequestKey(authState: HistoryAuthState, userId: string | null, focusVersion: number) {
return authState === 'signed-in' && userId ? `${userId}:${focusVersion}` : null
}
50 changes: 39 additions & 11 deletions apps/mobile/src/hooks/useHistoryScreenState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,51 +16,73 @@ import type {
MeteorVoiceApiClient,
SessionTurnDto,
} from '@meteorvoice/api-client'
import {
getHistoryLoadRequestKey,
type HistoryAuthState,
} from '../historyRefresh'

import {
formatApiRequestError,
MeteorVoiceApiError,
} from '@meteorvoice/api-client'

interface UseHistoryScreenStateInput {
api: MeteorVoiceApiClient
authState: HistoryAuthState
authUserId: string | null
handleUnauthorized: () => void
refreshKey: number
}

export function useHistoryScreenState({
api,
authState,
authUserId,
handleUnauthorized,
refreshKey,
}: UseHistoryScreenStateInput) {
const [expandedId, setExpandedId] = useState<string | number | null>(null)
const [filterScenario, setFilterScenario] = useState<string | null>(null)
const [sessions, setSessions] = useState<HistorySession[]>([])
const [loadedUserId, setLoadedUserId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedHistory, setSelectedHistory] = useState<HistorySession | null>(null)
const [selectedTurns, setSelectedTurns] = useState<SessionTurnDto[]>([])
const autoLoadRef = useRef(false)
const lastLoadKeyRef = useRef<string | null>(null)
const loadingRef = useRef(false)

const loadHistory = useCallback(async () => {
if (loading) return
if (loadingRef.current || authState !== 'signed-in' || !authUserId) return
loadingRef.current = true
setLoading(true)
setError(null)
try {
const result = await api.listHistory()
setSessions(result.sessions)
setLoadedUserId(authUserId)
setSelectedHistory(result.sessions[0] ?? null)
setSelectedTurns([])
} catch (error) {
if (error instanceof MeteorVoiceApiError && error.status === 401) handleUnauthorized()
const requestError = formatApiRequestError(error, { context: 'mobile_history_list', presentation: 'inline' })
setError(requestError.displayMessage)
} finally {
loadingRef.current = false
setLoading(false)
}
}, [api, loading])
}, [api, authState, authUserId, handleUnauthorized])

useEffect(() => {
if (autoLoadRef.current) return
autoLoadRef.current = true
const loadKey = getHistoryLoadRequestKey(authState, authUserId, refreshKey)
if (!loadKey) {
lastLoadKeyRef.current = null
return
}
if (lastLoadKeyRef.current === loadKey) return
lastLoadKeyRef.current = loadKey
void loadHistory()
}, [loadHistory])
}, [authState, authUserId, loadHistory, refreshKey])

const deleteSession = useCallback(async (id: string) => {
setSessions(prev => prev.map(session => session.id === id ? { ...session, status: 'deleted' as const } : session))
Expand All @@ -85,29 +107,35 @@ export function useHistoryScreenState({
}
}, [api])

const visibleSessions = useMemo(
() => authUserId && loadedUserId === authUserId ? sessions : [],
[authUserId, loadedUserId, sessions],
)

const toggle = useCallback((id: string | number) => {
if (expandedId === id) {
setExpandedId(null)
return
}
const nextSession = sessions.find(session => session.id === id)
const nextSession = visibleSessions.find(session => session.id === id)
if (!nextSession) return
setExpandedId(id)
void selectHistory(nextSession)
}, [expandedId, selectHistory, sessions])
}, [expandedId, selectHistory, visibleSessions])

const filtered = useMemo(() => (
filterScenario
? sessions.filter(session => session.scenario_key === filterScenario || session.scenario === filterScenario)
: sessions
), [filterScenario, sessions])
? visibleSessions.filter(session => session.scenario_key === filterScenario || session.scenario === filterScenario)
: visibleSessions
), [filterScenario, visibleSessions])

return {
deleteSession,
error,
expandedId,
filtered,
filterScenario,
hasSessions: visibleSessions.length > 0,
loadHistory,
loading,
selectedHistory,
Expand Down
Loading
Loading