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,