diff --git a/bun.lock b/bun.lock index 399ea09..e4e76fa 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "react-native-encrypted-storage": "^4.0.3", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.25.2", + "rn-emoji-keyboard": "^1.7.0", }, "devDependencies": { "@babel/core": "^7.25.2", @@ -1404,6 +1405,8 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rn-emoji-keyboard": ["rn-emoji-keyboard@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Yo5yJB+joYqSgvZx7WX/DMAC6rHUEJOQFYNjevd+jzhON23SSg/8kq2r9MH/kIdkNGdQWwIVBPowWqZwRQdfOA=="], + "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], diff --git a/package.json b/package.json index c510f66..0f45453 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "react-native": "0.85.3", "react-native-encrypted-storage": "^4.0.3", "react-native-safe-area-context": "^5.5.2", - "react-native-screens": "^4.25.2" + "react-native-screens": "^4.25.2", + "rn-emoji-keyboard": "^1.7.0" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/src/graphql/__generated__/types.ts b/src/graphql/__generated__/types.ts index c62e7a5..983b318 100644 --- a/src/graphql/__generated__/types.ts +++ b/src/graphql/__generated__/types.ts @@ -572,7 +572,7 @@ export type ElementDetailQueryVariables = Exact<{ }>; -export type ElementDetailQuery = { __typename?: 'RootQueryType', element: { __typename?: 'Element', id: string, name: string, icon: string, description: string, completed: boolean, labels: Array, location?: { __typename?: 'Location', id: string, address: string, latitude: number, longitude: number } | null, photos: Array<{ __typename?: 'Photo', id: string, thumbnail: string, regular: string, description: string }>, schedule?: { __typename?: 'Schedule', id: string, allDay: boolean, startDate: any, endDate: any, startTime?: any | null, endTime?: any | null } | null } }; +export type ElementDetailQuery = { __typename?: 'RootQueryType', element: { __typename?: 'Element', id: string, name: string, icon: string, description: string, completed: boolean, uri: string, labels: Array, trips: Array<{ __typename?: 'Trip', id: string }>, location?: { __typename?: 'Location', id: string, address: string, latitude: number, longitude: number, placeId?: string | null } | null, photos: Array<{ __typename?: 'Photo', id: string, thumbnail: string, regular: string, description: string }>, schedule?: { __typename?: 'Schedule', id: string, allDay: boolean, startDate: any, endDate: any, startTime?: any | null, endTime?: any | null, startTz: string, endTz: string } | null } }; export type ElementsQueryVariables = Exact<{ bounds?: InputMaybe; @@ -610,6 +610,13 @@ export type SearchQueryVariables = Exact<{ export type SearchQuery = { __typename?: 'RootQueryType', elements: Array<{ __typename?: 'Element', id: string, name: string, icon: string, location?: { __typename?: 'Location', id: string, address: string, latitude: number, longitude: number } | null }>, trips: Array<{ __typename?: 'Trip', id: string, name: string, icon: string, description: string }>, placeSearch: Array<{ __typename?: 'GooglePlace', placeId: string, name: string, address: string, latitude: number, longitude: number, types?: Array | null }> }; +export type UpdateElementMutationVariables = Exact<{ + input: ElementInput; +}>; + + +export type UpdateElementMutation = { __typename?: 'RootMutationType', updateElement: { __typename?: 'Element', id: string, name: string, icon: string, description: string, completed: boolean, uri: string, labels: Array, trips: Array<{ __typename?: 'Trip', id: string }>, location?: { __typename?: 'Location', id: string, address: string, latitude: number, longitude: number, placeId?: string | null } | null, photos: Array<{ __typename?: 'Photo', id: string, thumbnail: string, regular: string, description: string }>, schedule?: { __typename?: 'Schedule', id: string, allDay: boolean, startDate: any, endDate: any, startTime?: any | null, endTime?: any | null, startTz: string, endTz: string } | null } }; + export const ElementDetailDocument = gql` query ElementDetail($id: String!) { @@ -619,12 +626,17 @@ export const ElementDetailDocument = gql` icon description completed + uri labels + trips { + id + } location { id address latitude longitude + placeId } photos { id @@ -639,6 +651,8 @@ export const ElementDetailDocument = gql` endDate startTime endTime + startTz + endTz } } } @@ -907,4 +921,69 @@ export function useSearchSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.S export type SearchQueryHookResult = ReturnType; export type SearchLazyQueryHookResult = ReturnType; export type SearchSuspenseQueryHookResult = ReturnType; -export type SearchQueryResult = Apollo.QueryResult; \ No newline at end of file +export type SearchQueryResult = Apollo.QueryResult; +export const UpdateElementDocument = gql` + mutation UpdateElement($input: ElementInput!) { + updateElement(input: $input) { + id + name + icon + description + completed + uri + labels + trips { + id + } + location { + id + address + latitude + longitude + placeId + } + photos { + id + thumbnail + regular + description + } + schedule { + id + allDay + startDate + endDate + startTime + endTime + startTz + endTz + } + } +} + `; +export type UpdateElementMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateElementMutation__ + * + * To run a mutation, you first call `useUpdateElementMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateElementMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateElementMutation, { data, loading, error }] = useUpdateElementMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateElementMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateElementDocument, options); + } +export type UpdateElementMutationHookResult = ReturnType; +export type UpdateElementMutationResult = Apollo.MutationResult; +export type UpdateElementMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/src/graphql/queries/elementDetail.graphql b/src/graphql/queries/elementDetail.graphql index 1ccb35d..7bab30f 100644 --- a/src/graphql/queries/elementDetail.graphql +++ b/src/graphql/queries/elementDetail.graphql @@ -5,12 +5,17 @@ query ElementDetail($id: String!) { icon description completed + uri labels + trips { + id + } location { id address latitude longitude + placeId } photos { id @@ -25,6 +30,8 @@ query ElementDetail($id: String!) { endDate startTime endTime + startTz + endTz } } } diff --git a/src/graphql/queries/updateElement.graphql b/src/graphql/queries/updateElement.graphql new file mode 100644 index 0000000..a30f36e --- /dev/null +++ b/src/graphql/queries/updateElement.graphql @@ -0,0 +1,37 @@ +mutation UpdateElement($input: ElementInput!) { + updateElement(input: $input) { + id + name + icon + description + completed + uri + labels + trips { + id + } + location { + id + address + latitude + longitude + placeId + } + photos { + id + thumbnail + regular + description + } + schedule { + id + allDay + startDate + endDate + startTime + endTime + startTz + endTz + } + } +} diff --git a/src/map/ElementDetailScreen.tsx b/src/map/ElementDetailScreen.tsx index 8461cc8..0ed1fbd 100644 --- a/src/map/ElementDetailScreen.tsx +++ b/src/map/ElementDetailScreen.tsx @@ -29,6 +29,7 @@ export function ElementDetailScreen({route, navigation}: Props) { element={data?.element ?? null} loading={loading} onClose={() => navigation.goBack()} + onEdit={() => navigation.navigate('ElementEdit', {elementId})} /> ); @@ -38,10 +39,12 @@ function ModalContents({ element, loading, onClose, + onEdit, }: { element: ElementDetail | null; loading: boolean; onClose: () => void; + onEdit: () => void; }) { const safeAreaInsets = useSafeAreaInsets(); @@ -59,6 +62,16 @@ function ModalContents({ {element?.name ?? (loading ? 'Loading…' : ' ')} + {element ? ( + + Edit + + ) : null} {element ? ( @@ -199,6 +212,15 @@ const styles = StyleSheet.create({ fontWeight: '600', color: '#111', }, + editButton: { + marginLeft: 12, + paddingHorizontal: 4, + }, + editButtonText: { + fontSize: 16, + color: '#0a7ea4', + fontWeight: '600', + }, scrollContent: { padding: 16, }, diff --git a/src/map/ElementEditScreen.tsx b/src/map/ElementEditScreen.tsx new file mode 100644 index 0000000..995288e --- /dev/null +++ b/src/map/ElementEditScreen.tsx @@ -0,0 +1,339 @@ +import type {NativeStackScreenProps} from '@react-navigation/native-stack'; +import {useState} from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import EmojiPicker from 'rn-emoji-keyboard'; +import { + type ElementDetailQuery, + type ElementInput, + useElementDetailQuery, + useUpdateElementMutation, +} from '../graphql/__generated__/types'; +import type {RootStackParamList} from '../navigation/types'; + +type Props = NativeStackScreenProps; + +type Element = ElementDetailQuery['element']; + +// Requires an http(s) scheme and at least a host. We avoid the URL constructor +// since React Native's implementation is incomplete and inconsistent. +const URL_PATTERN = /^https?:\/\/[^\s/$.?#][^\s]*$/i; + +function isValidUrl(value: string): boolean { + return URL_PATTERN.test(value); +} + +export function ElementEditScreen({route, navigation}: Props) { + const {elementId} = route.params; + const {data, loading} = useElementDetailQuery({variables: {id: elementId}}); + const element = data?.element; + + return ( + + {element ? ( + navigation.goBack()} /> + ) : ( + + {loading ? : null} + + )} + + ); +} + +/** + * The form initializes its fields from the loaded element, so it's rendered + * only once the element is available. We round-trip every field the mutation + * requires — including the ones we don't expose (uri, icon, location, schedule, + * labels, trips) — so saving an edit doesn't clear them. + */ +function EditForm({element, onDone}: {element: Element; onDone: () => void}) { + const safeAreaInsets = useSafeAreaInsets(); + const [name, setName] = useState(element.name); + const [uri, setUri] = useState(element.uri); + const [icon, setIcon] = useState(element.icon); + const [description, setDescription] = useState(element.description); + const [completed, setCompleted] = useState(element.completed); + const [pickerOpen, setPickerOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [updateElement, {loading: saving}] = useUpdateElementMutation(); + + const trimmedUri = uri.trim(); + const uriValid = isValidUrl(trimmedUri); + const uriError = + trimmedUri.length > 0 && !uriValid ? 'Enter a valid URL.' : null; + + const canSave = name.trim().length > 0 && uriValid && !saving; + + async function onSave() { + setErrorMessage(null); + const input: ElementInput = { + id: element.id, + name: name.trim(), + uri: trimmedUri, + icon, + description, + completed, + // Preserved as-is — not editable here, but required by the mutation. + labels: element.labels, + tripIds: element.trips.map(trip => trip.id), + location: element.location + ? { + address: element.location.address, + latitude: element.location.latitude, + longitude: element.location.longitude, + placeId: element.location.placeId, + } + : undefined, + schedule: element.schedule + ? { + allDay: element.schedule.allDay, + startDate: element.schedule.startDate, + endDate: element.schedule.endDate, + startTime: element.schedule.startTime, + endTime: element.schedule.endTime, + startTz: element.schedule.startTz, + endTz: element.schedule.endTz, + } + : undefined, + }; + + try { + await updateElement({variables: {input}}); + onDone(); + } catch { + setErrorMessage('Could not save changes. Please try again.'); + } + } + + return ( + + + + Cancel + + + Edit element + + + {saving ? ( + + ) : ( + + Save + + )} + + + + + + + + + + setPickerOpen(true)} + style={styles.iconButton}> + {icon ? ( + {icon} + ) : ( + + )} + + + + + + {uriError ? {uriError} : null} + + + + Completed + + + + + + + + {errorMessage ? {errorMessage} : null} + + + setPickerOpen(false)} + onEmojiSelected={emoji => setIcon(emoji.emoji)} + /> + + ); +} + +function Field({label, children}: {label: string; children: React.ReactNode}) { + return ( + + {label} + {children} + + ); +} + +const styles = StyleSheet.create({ + flex: {flex: 1}, + screen: { + flex: 1, + backgroundColor: '#ffffff', + }, + loadingPane: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#ddd', + }, + headerButton: { + minWidth: 56, + justifyContent: 'center', + }, + headerButtonText: { + fontSize: 16, + color: '#222', + }, + headerTitle: { + flex: 1, + fontSize: 16, + fontWeight: '600', + color: '#111', + textAlign: 'center', + }, + saveText: { + color: '#0a7ea4', + fontWeight: '600', + textAlign: 'right', + }, + saveTextDisabled: { + opacity: 0.4, + }, + scrollContent: { + padding: 16, + gap: 20, + }, + field: { + gap: 6, + }, + fieldLabel: { + fontSize: 12, + fontWeight: '700', + color: '#888', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + input: { + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 16, + color: '#111', + }, + iconButton: { + width: 64, + height: 64, + borderRadius: 8, + borderWidth: 1, + borderColor: '#ccc', + alignItems: 'center', + justifyContent: 'center', + }, + iconButtonValue: { + fontSize: 32, + }, + iconButtonPlaceholder: { + fontSize: 28, + color: '#aaa', + }, + multiline: { + minHeight: 120, + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + error: { + color: '#c00', + fontSize: 14, + }, +}); diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 16277b3..e4fdd0c 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -1,5 +1,6 @@ import {createNativeStackNavigator} from '@react-navigation/native-stack'; import {ElementDetailScreen} from '../map/ElementDetailScreen'; +import {ElementEditScreen} from '../map/ElementEditScreen'; import {MapScreen} from '../map/MapScreen'; import type {RootStackParamList} from './types'; @@ -16,6 +17,7 @@ export function RootNavigator() { screenOptions={{headerShown: false, animation: 'slide_from_right'}}> + ); } diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 9cd71db..a47a072 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -8,6 +8,7 @@ import type {NativeStackNavigationProp} from '@react-navigation/native-stack'; export type RootStackParamList = { Map: undefined; ElementDetail: {elementId: string}; + ElementEdit: {elementId: string}; }; export type RootStackNavigation = NativeStackNavigationProp;