| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { AppText as Text, AppTextInput } from "../../components/AppText"; |
| 3 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 4 | import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; |
| 5 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 6 | import { useState } from "react"; |
| 7 | import { Platform, Pressable, View } from "react-native"; |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 9 | |
| 10 | import { projectEnvironment } from "../../state/projects"; |
| 11 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 12 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 13 | import { SettingsSection } from "./components/SettingsSection"; |
| 14 | import { |
| 15 | AndroidSettingsEnvironmentFilter, |
| 16 | SettingsEnvironmentFilterHeader, |
| 17 | } from "./components/SettingsEnvironmentFilterHeader"; |
| 18 | import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; |
| 19 | |
| 20 | export 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 | |
| 59 | function 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 | } |