Skip to content

Commit 522ddee

Browse files
feat(mobile): make the local model row the model picker
The method section showed the local model twice: a Local model choice and a separate Model row that opened the picker. The model row now carries the checkmark itself, so choosing local and opening the model list are one row. ListRow gains an optional selected mark for this. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 38beb94 commit 522ddee

3 files changed

Lines changed: 86 additions & 72 deletions

File tree

‎apps/mobile/src/components/ui/ListRow.tsx‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
1-
import { Platform } from "react-native";
1+
import { Platform, View } from "react-native";
22

33
import { useResolveClassNames } from "uniwind";
44
import { SymbolView } from "./AppSymbol";
55
import { ListRowLayout, type ListRowContentProps } from "./ListRowLayout";
6+
import { RadioIndicator } from "./RadioIndicator";
67
import { RowPressable } from "./RowPressable";
78

8-
export function ListRow(props: ListRowContentProps & { readonly onPress: () => void }) {
9+
export function ListRow(
10+
props: ListRowContentProps & {
11+
readonly onPress: () => void;
12+
/** Marks the row as the chosen option while it still navigates. */
13+
readonly selected?: boolean;
14+
},
15+
) {
916
const rippleStyle = useResolveClassNames("bg-subtle-strong");
1017
return (
1118
<RowPressable
1219
accessibilityRole="button"
1320
accessibilityLabel={[props.label, props.description, props.detail].filter(Boolean).join(", ")}
14-
accessibilityState={{ disabled: Boolean(props.disabled) }}
21+
accessibilityState={{
22+
disabled: Boolean(props.disabled),
23+
...(props.selected === undefined ? {} : { checked: props.selected }),
24+
}}
1525
disabled={props.disabled}
1626
onPress={props.onPress}
1727
android_ripple={{ color: rippleStyle.backgroundColor }}
@@ -21,13 +31,34 @@ export function ListRow(props: ListRowContentProps & { readonly onPress: () => v
2131
<ListRowLayout
2232
{...props}
2333
accessory={
24-
<SymbolView
25-
name="chevron.right"
26-
size={16}
27-
tintColorClassName="accent-chevron"
28-
type="monochrome"
29-
weight="semibold"
30-
/>
34+
<View className="flex-row items-center gap-3">
35+
{props.selected === undefined ? null : (
36+
<View
37+
accessibilityElementsHidden
38+
importantForAccessibility="no-hide-descendants"
39+
className="size-6 items-center justify-center"
40+
>
41+
{Platform.OS === "android" ? (
42+
<RadioIndicator selected={props.selected} />
43+
) : props.selected ? (
44+
<SymbolView
45+
name="checkmark"
46+
size={18}
47+
tintColorClassName="accent-primary-text"
48+
type="monochrome"
49+
weight="semibold"
50+
/>
51+
) : null}
52+
</View>
53+
)}
54+
<SymbolView
55+
name="chevron.right"
56+
size={16}
57+
tintColorClassName="accent-chevron"
58+
type="monochrome"
59+
weight="semibold"
60+
/>
61+
</View>
3162
}
3263
/>
3364
</RowPressable>

‎apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx‎

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@ import {
2727
cloudVoiceConnectionAtom,
2828
voiceConnectionAtom,
2929
voiceGrantAtom,
30-
voiceSpeechStatusAtom,
3130
hasEnvironmentVoiceCapture,
3231
} from "../voice-input/voiceDestination";
33-
import { VoiceModelSection } from "./VoiceModelSection";
32+
import { LocalModelChoice } from "./VoiceModelSection";
3433
import { CloudVoiceChoice } from "./CloudVoiceSection";
3534
import type { VoiceGrant } from "../voice-input/voiceGrantStorage";
3635
import type { EnvironmentId } from "@t3tools/contracts";
@@ -179,33 +178,18 @@ function EnvironmentMethodSection({
179178
onCloudOpenChange: (open: boolean) => void;
180179
}) {
181180
const id = environment.environmentId;
182-
const local = useAtomValue(voiceConnectionAtom(id)) !== null;
183-
const status = AsyncResult.getOrElse(useAtomValue(voiceSpeechStatusAtom(id)), () => null);
184181
const destination = grant?.destination.environmentId === id ? grant.destination : null;
185182
const cloudCapable =
186183
environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true ||
187184
destination?.kind === "cloud";
188185
return (
189186
<SettingsSection title={`Method on ${environment.label}`}>
190-
<ListChoiceRow
191-
label="Local model"
192-
description={
193-
!local
194-
? "Not available on this environment"
195-
: status?.supported === false
196-
? status.reason
197-
: status?.state === "missing-model"
198-
? "No model installed"
199-
: status
200-
? status.model
201-
: "Runs on the environment"
202-
}
187+
<LocalModelChoice
188+
environmentId={id}
189+
label={environment.label}
203190
selected={destination?.kind === "environment"}
204-
disabled={!local}
205-
onPress={() => {
206-
// The user already agreed to send audio to this environment.
207-
if (destination?.kind !== "environment") saveDestination(id);
208-
}}
191+
// The user already agreed to send audio to this environment.
192+
onSelect={() => saveDestination(id)}
209193
/>
210194
{cloudCapable ? (
211195
<CloudVoiceChoice
@@ -330,14 +314,6 @@ export function SettingsVoiceRouteScreen() {
330314
}
331315
/>
332316
) : null}
333-
{grantEnvironment &&
334-
methodEnvironment === grantEnvironment &&
335-
grant?.destination.kind === "environment" ? (
336-
<VoiceModelSection
337-
environmentId={grantEnvironment.environmentId}
338-
label={grantEnvironment.label}
339-
/>
340-
) : null}
341317
{!grant && transcriber === null ? (
342318
<Text className="px-2 text-sm text-foreground-muted">{UNAVAILABLE_EXPLANATION}</Text>
343319
) : null}

‎apps/mobile/src/features/settings/VoiceModelSection.tsx‎

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import {
2929
voiceSpeechModelsAtom,
3030
voiceSpeechStatusAtom,
3131
} from "../voice-input/voiceDestination";
32-
import { SettingsSection } from "./components/SettingsSection";
3332

3433
const isDownloading = (model: EnvironmentSpeechModel) =>
3534
model.state === "downloading" || model.state === "verifying";
@@ -272,51 +271,59 @@ export function VoiceModelRow(props: {
272271
);
273272
}
274273

275-
export function VoiceModelSection(props: {
274+
/** The environment's local-model method: chosen by its mark, opens the model list. */
275+
export function LocalModelChoice(props: {
276276
readonly environmentId: EnvironmentId;
277277
readonly label: string;
278+
readonly selected: boolean;
279+
readonly onSelect: () => void;
278280
}) {
279281
const navigation = useNavigation();
280282
const voice = useVoiceModels(props.environmentId, props.label);
281283
const { status } = voice;
282-
if (!voice.connection || !status) return null;
283-
const title = "Model";
284-
if (!status.supported) {
284+
const open = () =>
285+
navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId });
286+
if (!voice.connection || !status || !status.supported)
285287
return (
286-
<SettingsSection title={title} footer={status.reason}>
287-
{null}
288-
</SettingsSection>
288+
<ListChoiceRow
289+
label="Local model"
290+
description={
291+
!voice.connection
292+
? "Not available on this environment"
293+
: status?.supported === false
294+
? status.reason
295+
: "Checking…"
296+
}
297+
selected={props.selected}
298+
disabled
299+
onPress={() => {}}
300+
/>
289301
);
290-
}
291302

292303
const active = status.state === "missing-model" ? null : status;
293304
const pending = voice.models.find(isDownloading);
294305
const progress =
295306
pending?.downloaded === undefined ? undefined : pending.downloaded / pending.size;
296307
return (
297-
<SettingsSection
298-
title={title}
299-
footer={
300-
voice.canManage
301-
? undefined
302-
: `This connection can’t change settings on ${props.label}. Add a transcription model using T3 Code on that machine.`
308+
<ListRow
309+
label={active?.model ?? "Local model"}
310+
description={
311+
pending
312+
? pending.state === "verifying"
313+
? `Verifying ${pending.name}…`
314+
: `Downloading ${pending.name} · ${Math.round((progress ?? 0) * 100)}%`
315+
: active
316+
? `Local model · ${formatBytes(active.size)}`
317+
: voice.canManage
318+
? "No model installed · Choose one to download"
319+
: `No model installed · Add one using T3 Code on ${props.label}`
303320
}
304-
>
305-
<ListRow
306-
label={active?.model ?? "No model selected"}
307-
description={
308-
pending
309-
? pending.state === "verifying"
310-
? `Verifying ${pending.name}…`
311-
: `Downloading ${pending.name} · ${Math.round((progress ?? 0) * 100)}%`
312-
: active
313-
? formatBytes(active.size)
314-
: "Choose a transcription model to dictate on this environment."
315-
}
316-
onPress={() =>
317-
navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId })
318-
}
319-
/>
320-
</SettingsSection>
321+
selected={props.selected}
322+
onPress={() => {
323+
if (!props.selected) props.onSelect();
324+
// Switching to an installed model needs no further step.
325+
if (props.selected || !active) open();
326+
}}
327+
/>
321328
);
322329
}

0 commit comments

Comments
 (0)