From a2e905a8d6261e96aa31707f111788c605c09011 Mon Sep 17 00:00:00 2001 From: Dennis Falling Date: Wed, 27 May 2026 22:09:14 +0100 Subject: [PATCH] Tap a map pin to preview and expand element details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a marker brings up a bottom preview card with name, address, and a description snippet. Tapping the card opens a full-screen modal with photos, schedule, labels, and the rest. Tapping elsewhere on the map dismisses the preview. Marker and Map onPress are both codegen BubblingEventHandlers, so the marker's "onPress" bubbles to the map's onPress listener — handler calls stopPropagation() to keep the map from clearing the selection right after. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/graphql/__generated__/types.ts | 67 +++++ src/graphql/queries/elementDetail.graphql | 30 +++ src/map/ElementDetailModal.tsx | 300 ++++++++++++++++++++++ src/map/ElementPreviewCard.tsx | 120 +++++++++ src/map/MapScreen.tsx | 40 ++- 5 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 src/graphql/queries/elementDetail.graphql create mode 100644 src/map/ElementDetailModal.tsx create mode 100644 src/map/ElementPreviewCard.tsx diff --git a/src/graphql/__generated__/types.ts b/src/graphql/__generated__/types.ts index bb5f6a3..6575d85 100644 --- a/src/graphql/__generated__/types.ts +++ b/src/graphql/__generated__/types.ts @@ -543,6 +543,13 @@ export type ElementsQueryVariables = Exact<{ export type ElementsQuery = { __typename?: 'RootQueryType', elements: Array<{ __typename?: 'Element', id: string, name: string, icon: string, location?: { __typename?: 'Location', id: string, latitude: number, longitude: number } | null }> }; +export type ElementDetailQueryVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +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 LoginMutationVariables = Exact<{ input: LoginInput; }>; @@ -615,6 +622,66 @@ export type ElementsQueryHookResult = ReturnType; export type ElementsLazyQueryHookResult = ReturnType; export type ElementsSuspenseQueryHookResult = ReturnType; export type ElementsQueryResult = Apollo.QueryResult; +export const ElementDetailDocument = gql` + query ElementDetail($id: String!) { + element(id: $id) { + id + name + icon + description + completed + labels + location { + id + address + latitude + longitude + } + photos { + id + thumbnail + regular + description + } + schedule { + id + allDay + startDate + endDate + startTime + endTime + } + } +} + `; + +/** + * __useElementDetailQuery__ + * + * To run a query within a React component, call `useElementDetailQuery` and pass it any options that fit your needs. + * When your component renders, `useElementDetailQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useElementDetailQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useElementDetailQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: ElementDetailQueryVariables; skip?: boolean; } | { skip: boolean; })) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ElementDetailDocument, options); + } +export function useElementDetailLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ElementDetailDocument, options); + } +export type ElementDetailQueryHookResult = ReturnType; +export type ElementDetailLazyQueryHookResult = ReturnType; +export type ElementDetailQueryResult = Apollo.QueryResult; export const LoginDocument = gql` mutation Login($input: LoginInput!) { login(input: $input) { diff --git a/src/graphql/queries/elementDetail.graphql b/src/graphql/queries/elementDetail.graphql new file mode 100644 index 0000000..1ccb35d --- /dev/null +++ b/src/graphql/queries/elementDetail.graphql @@ -0,0 +1,30 @@ +query ElementDetail($id: String!) { + element(id: $id) { + id + name + icon + description + completed + labels + location { + id + address + latitude + longitude + } + photos { + id + thumbnail + regular + description + } + schedule { + id + allDay + startDate + endDate + startTime + endTime + } + } +} diff --git a/src/map/ElementDetailModal.tsx b/src/map/ElementDetailModal.tsx new file mode 100644 index 0000000..05742ae --- /dev/null +++ b/src/map/ElementDetailModal.tsx @@ -0,0 +1,300 @@ +import { + ActivityIndicator, + Image, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import { + type ElementDetailQuery, + useElementDetailQuery, +} from '../graphql/__generated__/types'; + +type Props = { + elementId: string | null; + onClose: () => void; +}; + +type ElementDetail = ElementDetailQuery['element']; + +export function ElementDetailModal({elementId, onClose}: Props) { + const {data, loading} = useElementDetailQuery({ + variables: {id: elementId ?? ''}, + skip: !elementId, + }); + + return ( + + + + ); +} + +function ModalContents({ + element, + loading, + onClose, +}: { + element: ElementDetail | null; + loading: boolean; + onClose: () => void; +}) { + const safeAreaInsets = useSafeAreaInsets(); + + return ( + + + + × + + + {element?.name ?? (loading ? 'Loading…' : ' ')} + + + + {element ? ( + + + + {element.icon ? ( + {element.icon} + ) : null} + + + {element.name} + {element.location?.address ? ( + {element.location.address} + ) : null} + {element.completed ? ( + + Completed + + ) : null} + + + + {element.photos.length > 0 ? ( + + {element.photos.map(photo => ( + + ))} + + ) : null} + + {element.description ? ( +
+ {element.description} +
+ ) : null} + + {element.schedule ? ( +
+ + {formatSchedule(element.schedule)} + +
+ ) : null} + + {element.labels.length > 0 ? ( +
+ + {element.labels.map(label => ( + + {label} + + ))} + +
+ ) : null} +
+ ) : ( + + {loading ? : null} + + )} +
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +function formatSchedule(schedule: NonNullable) { + const {allDay, startDate, endDate, startTime, endTime} = schedule; + const range = startDate === endDate ? startDate : `${startDate} – ${endDate}`; + if (allDay || (!startTime && !endTime)) return range; + const time = + startTime && endTime + ? `${startTime}–${endTime}` + : (startTime ?? endTime ?? ''); + return `${range} · ${time}`; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#ddd', + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f1f1f1', + marginRight: 12, + }, + closeIcon: { + fontSize: 20, + lineHeight: 22, + color: '#222', + }, + headerTitle: { + flex: 1, + fontSize: 16, + fontWeight: '600', + color: '#111', + }, + scrollContent: { + padding: 16, + }, + hero: { + flexDirection: 'row', + alignItems: 'center', + }, + iconWrap: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#1d6fe0', + alignItems: 'center', + justifyContent: 'center', + marginRight: 14, + }, + icon: { + fontSize: 28, + lineHeight: 32, + textAlign: 'center', + }, + heroBody: { + flex: 1, + }, + title: { + fontSize: 22, + fontWeight: '700', + color: '#111', + }, + subtitle: { + fontSize: 13, + color: '#666', + marginTop: 4, + }, + completedTag: { + alignSelf: 'flex-start', + backgroundColor: '#e6f4ea', + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 10, + marginTop: 8, + }, + completedTagText: { + color: '#1e8e3e', + fontSize: 11, + fontWeight: '600', + }, + photoStrip: { + paddingVertical: 16, + gap: 8, + }, + photo: { + width: 200, + height: 140, + borderRadius: 10, + backgroundColor: '#eee', + }, + section: { + marginTop: 20, + }, + sectionTitle: { + fontSize: 12, + fontWeight: '700', + color: '#888', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 6, + }, + body: { + fontSize: 15, + lineHeight: 21, + color: '#222', + }, + labelRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + }, + labelChip: { + backgroundColor: '#eef3fb', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 12, + }, + labelChipText: { + color: '#1d6fe0', + fontSize: 12, + fontWeight: '500', + }, + loadingPane: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/map/ElementPreviewCard.tsx b/src/map/ElementPreviewCard.tsx new file mode 100644 index 0000000..f17a10a --- /dev/null +++ b/src/map/ElementPreviewCard.tsx @@ -0,0 +1,120 @@ +import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {useElementDetailQuery} from '../graphql/__generated__/types'; + +type Props = { + elementId: string; + bottomOffset: number; + onClose: () => void; + onExpand: () => void; +}; + +export function ElementPreviewCard({ + elementId, + bottomOffset, + onClose, + onExpand, +}: Props) { + const {data, loading} = useElementDetailQuery({variables: {id: elementId}}); + const element = data?.element; + + return ( + + + {element?.icon ? {element.icon} : null} + + + + {element?.name ?? (loading ? 'Loading…' : ' ')} + + {element?.location?.address ? ( + + {element.location.address} + + ) : null} + {element?.description ? ( + + {element.description} + + ) : null} + + + × + + + ); +} + +const styles = StyleSheet.create({ + card: { + position: 'absolute', + left: 12, + right: 12, + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 12, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 8, + shadowOffset: {width: 0, height: 2}, + elevation: 6, + }, + iconWrap: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#1d6fe0', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + icon: { + fontSize: 22, + lineHeight: 26, + textAlign: 'center', + }, + body: { + flex: 1, + marginRight: 8, + }, + title: { + fontSize: 16, + fontWeight: '600', + color: '#111', + }, + subtitle: { + fontSize: 12, + color: '#666', + marginTop: 2, + }, + description: { + fontSize: 13, + color: '#444', + marginTop: 4, + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f1f1f1', + }, + closeIcon: { + fontSize: 20, + lineHeight: 22, + color: '#444', + }, +}); diff --git a/src/map/MapScreen.tsx b/src/map/MapScreen.tsx index 0f453cf..c2a2094 100644 --- a/src/map/MapScreen.tsx +++ b/src/map/MapScreen.tsx @@ -17,6 +17,8 @@ import { type ElementsQueryVariables, useElementsQuery, } from '../graphql/__generated__/types'; +import {ElementDetailModal} from './ElementDetailModal'; +import {ElementPreviewCard} from './ElementPreviewCard'; import {type Viewport, viewportStore} from './viewportStore'; type ElementWithLocation = ElementsQuery['elements'][number]; @@ -42,6 +44,10 @@ export function MapScreen() { const [savedViewport, setSavedViewport] = useState< Viewport | null | undefined >(undefined); + const [selectedElementId, setSelectedElementId] = useState( + null, + ); + const [detailExpanded, setDetailExpanded] = useState(false); const safeAreaInsets = useSafeAreaInsets(); useEffect(() => { @@ -192,6 +198,7 @@ export function MapScreen() { setSelectedElementId(null)} onRegionDidChange={onRegionDidChange}> @@ -200,15 +207,26 @@ export function MapScreen() { - + lngLat={[el.location.longitude, el.location.latitude]} + onPress={e => { + // The marker's "onPress" event bubbles up to the Map's + // onPress (both are codegen BubblingEventHandlers), which + // would immediately clear the selection we just set. + e.stopPropagation(); + setSelectedElementId(el.id); + }}> + {el.icon ? {el.icon} : null} ) : null, )} - {position && !isCenteredOnUser ? ( + {position && !isCenteredOnUser && !selectedElementId ? ( ) : null} + {selectedElementId ? ( + setSelectedElementId(null)} + onExpand={() => setDetailExpanded(true)} + /> + ) : null} + setDetailExpanded(false)} + /> ); } @@ -241,6 +271,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + pinSelected: { + backgroundColor: '#0b4ea2', + transform: [{scale: 1.15}], + }, pinIcon: { fontSize: 18, lineHeight: 22,