1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { AppText as Text, AppTextInput } from "../../components/AppText";
3import { ProjectFavicon } from "../../components/ProjectFavicon";
4import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping";
5import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
6import { useState } from "react";
7import { Platform, Pressable, View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { projectEnvironment } from "../../state/projects";
11import { useAtomCommand } from "../../state/use-atom-command";
12import { SettingsScreen } from "./components/SettingsScreen";
13import { SettingsSection } from "./components/SettingsSection";
14import {
15 AndroidSettingsEnvironmentFilter,
16 SettingsEnvironmentFilterHeader,
17} from "./components/SettingsEnvironmentFilterHeader";
18import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
19
20export function SettingsProjectOverviewRouteScreen() {
21 const insets = useSafeAreaInsets();
22 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
23 const group = projectGroups.find((entry) => entry.key === selectedProjectKey);
24 const selectedEnvironmentIds = new Set(selectedTargets.map((entry) => entry.environmentId));
25 const members =
26 group?.members
27 .map((entry) => entry.project)
28 .filter((project) => selectedEnvironmentIds.has(project.environmentId)) ?? [];
29
30 return (
31 <>
32 <SettingsEnvironmentFilterHeader />
33 <SettingsScreen title="Project overview" trailing={<AndroidSettingsEnvironmentFilter />}>
34 <ScrollView
35 contentInsetAdjustmentBehavior="automatic"
36 showsVerticalScrollIndicator={false}
37 className="flex-1"
38 contentContainerClassName="gap-6 px-5 pt-4"
39 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
40 >
41 {members.length === 0 ? (
42 <Text className="px-2 text-base text-foreground-muted">
43 This project has no checkout on the selected connected environments. Change the filter
44 above.
45 </Text>
46 ) : (
47 <ProjectOverviewContent
48 key={`${selectedProjectKey}:${members.map((member) => member.id).join(",")}`}
49 members={members}
50 environments={selectedTargets}
51 />
52 )}
53 </ScrollView>
54 </SettingsScreen>
55 </>
56 );
57}
58
59function ProjectOverviewContent(props: {
60 readonly members: readonly EnvironmentProject[];
61 readonly environments: readonly SettingsTarget[];
62}) {
63 const representative = props.members[0]!;
64 const displayName = deriveProjectGroupLabel({ representative, members: props.members });
65 const [draftName, setDraftName] = useState<string | null>(null);
66 const [isSaving, setIsSaving] = useState(false);
67 const updateProject = useAtomCommand(projectEnvironment.update, {
68 label: "project name update",
69 reportFailure: true,
70 });
71 const nextName = (draftName ?? displayName).trim();
72 const canSave = !isSaving && nextName.length > 0 && nextName !== displayName;
73
74 const saveName = () => {
75 if (!canSave) return;
76 setIsSaving(true);
77 void (async () => {
78 try {
79 const results = await Promise.all(
80 props.members.map((member) =>
81 updateProject({
82 environmentId: member.environmentId,
83 input: { projectId: member.id, title: nextName },
84 }),
85 ),
86 );
87 if (results.every((result) => result._tag !== "Failure")) setDraftName(null);
88 } finally {
89 setIsSaving(false);
90 }
91 })();
92 };
93
94 return (
95 <>
96 <View className="flex-row items-center gap-4 px-2">
97 <ProjectFavicon
98 environmentId={representative.environmentId}
99 projectTitle={representative.title}
100 workspaceRoot={representative.workspaceRoot}
101 faviconPath={representative.faviconPath}
102 projectIcon={representative.projectIcon}
103 size={48}
104 />
105 <View className="min-w-0 flex-1">
106 <Text className="text-xl font-t3-semibold text-foreground" numberOfLines={2}>
107 {displayName}
108 </Text>
109 <Text className="text-sm text-foreground-muted">
110 {props.members.length === 1 ? "1 checkout" : `${props.members.length} checkouts`}
111 </Text>
112 </View>
113 </View>
114
115 <SettingsSection title="Project">
116 <View className="gap-3 p-4">
117 <Text className="text-sm font-t3-medium text-foreground-muted">Name</Text>
118 <View className="flex-row items-center gap-3">
119 <AppTextInput
120 accessibilityLabel="Project name"
121 className="min-h-11 min-w-0 flex-1 rounded-xl border-continuous bg-card px-3 text-base text-foreground"
122 value={draftName ?? displayName}
123 onChangeText={setDraftName}
124 onSubmitEditing={saveName}
125 returnKeyType="done"
126 editable={!isSaving}
127 />
128 {canSave ? (
129 <Pressable
130 accessibilityRole="button"
131 accessibilityLabel="Save project name"
132 onPress={saveName}
133 className="rounded-full bg-subtle-strong px-4 py-2 active:opacity-70"
134 >
135 <Text className="text-sm font-t3-medium text-foreground">Save</Text>
136 </Pressable>
137 ) : null}
138 </View>
139 </View>
140 </SettingsSection>
141
142 <SettingsSection title="Checkouts">
143 {props.members.map((member, index) => {
144 const environment = props.environments.find(
145 (entry) => entry.environmentId === member.environmentId,
146 );
147 return (
148 <View
149 key={`${member.environmentId}:${member.id}`}
150 className={index === 0 ? "gap-1 p-4" : "gap-1 border-t border-border-subtle p-4"}
151 >
152 <Text
153 className={
154 Platform.OS === "android"
155 ? "text-base text-foreground"
156 : "text-lg text-foreground"
157 }
158 >
159 {environment?.label ?? "Environment"}
160 </Text>
161 {environment?.displayUrl ? (
162 <Text className="text-sm leading-normal text-foreground-muted">
163 {environment.displayUrl}
164 </Text>
165 ) : null}
166 <Text className="text-sm leading-normal text-foreground-muted" selectable>
167 {member.workspaceRoot}
168 </Text>
169 </View>
170 );
171 })}
172 </SettingsSection>
173 </>
174 );
175}