diff --git a/bun.lock b/bun.lock index fd0b2af..f7261c9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "Culpeos", @@ -11,7 +10,7 @@ "@react-navigation/native": "^7.2.5", "@react-navigation/native-stack": "^7.16.0", "graphql": "^16.9.0", - "react": "19.2.7", + "react": "19.2.3", "react-native": "0.85.3", "react-native-encrypted-storage": "^4.0.3", "react-native-safe-area-context": "^5.5.2", @@ -1346,7 +1345,7 @@ "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], diff --git a/package.json b/package.json index cbffad1..38e8902 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@react-navigation/native": "^7.2.5", "@react-navigation/native-stack": "^7.16.0", "graphql": "^16.9.0", - "react": "19.2.7", + "react": "19.2.3", "react-native": "0.85.3", "react-native-encrypted-storage": "^4.0.3", "react-native-safe-area-context": "^5.5.2", diff --git a/src/graphql/__generated__/types.ts b/src/graphql/__generated__/types.ts index 983b318..971e9e7 100644 --- a/src/graphql/__generated__/types.ts +++ b/src/graphql/__generated__/types.ts @@ -577,10 +577,12 @@ export type ElementDetailQuery = { __typename?: 'RootQueryType', element: { __ty export type ElementsQueryVariables = Exact<{ bounds?: InputMaybe; tripId?: InputMaybe; + labels?: InputMaybe | Scalars['String']['input']>; + labelsMatch?: InputMaybe; }>; -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 ElementsQuery = { __typename?: 'RootQueryType', elements: Array<{ __typename?: 'Element', id: string, name: string, icon: string, labels: Array, location?: { __typename?: 'Location', id: string, latitude: number, longitude: number } | null }> }; export type LoginMutationVariables = Exact<{ input: LoginInput; @@ -608,7 +610,7 @@ 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 SearchQuery = { __typename?: 'RootQueryType', elements: Array<{ __typename?: 'Element', id: string, name: string, icon: string, labels: Array, 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; @@ -694,11 +696,17 @@ export type ElementDetailLazyQueryHookResult = ReturnType; export type ElementDetailQueryResult = Apollo.QueryResult; export const ElementsDocument = gql` - query Elements($bounds: GeoBounds, $tripId: String) { - elements(bounds: $bounds, tripId: $tripId) { + query Elements($bounds: GeoBounds, $tripId: String, $labels: [String!], $labelsMatch: LabelMatchMode) { + elements( + bounds: $bounds + tripId: $tripId + labels: $labels + labelsMatch: $labelsMatch + ) { id name icon + labels location { id latitude @@ -863,6 +871,7 @@ export const SearchDocument = gql` id name icon + labels location { id address diff --git a/src/graphql/queries/elements.graphql b/src/graphql/queries/elements.graphql index 2846dc6..b9aff79 100644 --- a/src/graphql/queries/elements.graphql +++ b/src/graphql/queries/elements.graphql @@ -1,8 +1,19 @@ -query Elements($bounds: GeoBounds, $tripId: String) { - elements(bounds: $bounds, tripId: $tripId) { +query Elements( + $bounds: GeoBounds + $tripId: String + $labels: [String!] + $labelsMatch: LabelMatchMode +) { + elements( + bounds: $bounds + tripId: $tripId + labels: $labels + labelsMatch: $labelsMatch + ) { id name icon + labels location { id latitude diff --git a/src/graphql/queries/search.graphql b/src/graphql/queries/search.graphql index 89c647e..062badd 100644 --- a/src/graphql/queries/search.graphql +++ b/src/graphql/queries/search.graphql @@ -3,6 +3,7 @@ query Search($query: String!) { id name icon + labels location { id address diff --git a/src/map/ElementDetailScreen.tsx b/src/map/ElementDetailScreen.tsx index 0ed1fbd..1764785 100644 --- a/src/map/ElementDetailScreen.tsx +++ b/src/map/ElementDetailScreen.tsx @@ -30,6 +30,12 @@ export function ElementDetailScreen({route, navigation}: Props) { loading={loading} onClose={() => navigation.goBack()} onEdit={() => navigation.navigate('ElementEdit', {elementId})} + // Tapping a label closes the details and adds it to the map's active + // filters. popTo (not navigate) so the detail screen animates backward + // off the stack — closing — rather than pushing a new level forward. + onSelectLabel={label => + navigation.popTo('Map', {addLabelFilter: label}) + } /> ); @@ -40,11 +46,13 @@ function ModalContents({ loading, onClose, onEdit, + onSelectLabel, }: { element: ElementDetail | null; loading: boolean; onClose: () => void; onEdit: () => void; + onSelectLabel: (label: string) => void; }) { const safeAreaInsets = useSafeAreaInsets(); @@ -132,9 +140,17 @@ function ModalContents({
{element.labels.map(label => ( - + onSelectLabel(label)} + style={({pressed}) => [ + styles.labelChip, + pressed && styles.labelChipPressed, + ]}> {label} - + ))}
@@ -305,6 +321,9 @@ const styles = StyleSheet.create({ paddingVertical: 5, borderRadius: 12, }, + labelChipPressed: { + backgroundColor: '#dde7f7', + }, labelChipText: { color: '#1d6fe0', fontSize: 12, diff --git a/src/map/FilterChips.tsx b/src/map/FilterChips.tsx new file mode 100644 index 0000000..8777a28 --- /dev/null +++ b/src/map/FilterChips.tsx @@ -0,0 +1,139 @@ +import {Pressable, StyleSheet, Text, View} from 'react-native'; + +// Stands in for the trip's emoji on a label pill, so the two filter kinds read +// differently while sharing one pill style. +const LABEL_ICON = '🏷️'; + +type Props = { + /** Active trip filter, or null when not filtering by trip. */ + trip: {icon: string; name: string} | null; + /** Active label filters; rendered as one pill each. */ + labels: readonly string[]; + topOffset: number; + onClearTrip: () => void; + onClearLabel: (label: string) => void; +}; + +/** + * Floating row of active map filters. Shows the trip filter (if any) followed by + * a pill per active label; each pill has an `×` to clear just that filter. Trip + * and label filters combine, so several pills can be shown at once and they wrap + * onto further lines when they don't fit. + */ +export function FilterChips({ + trip, + labels, + topOffset, + onClearTrip, + onClearLabel, +}: Props) { + if (!trip && labels.length === 0) return null; + return ( + + + {trip ? ( + + ) : null} + {labels.map(label => ( + onClearLabel(label)} + /> + ))} + + + ); +} + +/** A single filter pill: leading icon, title, and an `×` to clear it. */ +function FilterPill({ + icon, + title, + accessibilityLabel, + onClear, +}: { + icon: string; + title: string; + accessibilityLabel: string; + onClear: () => void; +}) { + return ( + + {icon ? {icon} : null} + + {title} + + + × + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + position: 'absolute', + left: 16, + right: 16, + alignItems: 'center', + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 8, + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + maxWidth: '100%', + backgroundColor: '#ffffff', + borderRadius: 20, + paddingLeft: 14, + paddingRight: 6, + paddingVertical: 6, + shadowColor: '#000', + shadowOpacity: 0.2, + shadowRadius: 4, + shadowOffset: {width: 0, height: 2}, + elevation: 4, + }, + icon: { + fontSize: 15, + lineHeight: 18, + marginRight: 6, + }, + title: { + flexShrink: 1, + fontSize: 14, + fontWeight: '600', + color: '#1d6fe0', + }, + clearButton: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 8, + backgroundColor: '#eef3fb', + }, + clearIcon: { + color: '#1d6fe0', + fontSize: 16, + lineHeight: 18, + }, +}); diff --git a/src/map/MapScreen.tsx b/src/map/MapScreen.tsx index 1cc0e27..6af633c 100644 --- a/src/map/MapScreen.tsx +++ b/src/map/MapScreen.tsx @@ -8,7 +8,11 @@ import { useCurrentPosition, type ViewStateChangeEvent, } from '@maplibre/maplibre-react-native'; -import {useNavigation} from '@react-navigation/native'; +import { + type RouteProp, + useNavigation, + useRoute, +} from '@react-navigation/native'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; @@ -17,10 +21,15 @@ import {AccountMenu} from '../account/AccountMenu'; import { type ElementsQuery, type ElementsQueryVariables, + LabelMatchMode, useElementsQuery, } from '../graphql/__generated__/types'; -import type {RootStackNavigation} from '../navigation/types'; +import type { + RootStackNavigation, + RootStackParamList, +} from '../navigation/types'; import {ElementPreviewCard} from './ElementPreviewCard'; +import {FilterChips} from './FilterChips'; import {zoomForPlaceTypes} from './placeZoom'; import { type SearchElement, @@ -28,7 +37,6 @@ import { type SearchPlace, type SearchTrip, } from './SearchOverlay'; -import {TripFilterChip} from './TripFilterChip'; import {type Viewport, viewportStore} from './viewportStore'; type ElementWithLocation = ElementsQuery['elements'][number]; @@ -52,6 +60,7 @@ const BOTTOM_MARGIN = 16; export function MapScreen() { const navigation = useNavigation(); + const route = useRoute>(); const cameraRef = useRef(null); // Gate the bounds fetch + viewport-save until we know the map is sitting on // a real location — either a restored viewport or a fly-to-user. Prevents @@ -76,8 +85,24 @@ export function MapScreen() { name: string; icon: string; } | null>(null); + // Labels the map is filtered by, combined with the trip filter (if any) and + // matched with ALL semantics so each added label narrows the results further. + const [labelFilters, setLabelFilters] = useState([]); const safeAreaInsets = useSafeAreaInsets(); + // A label tapped in the element details navigates back here with the label in + // route params; fold it into the active filters, then clear the param so it + // isn't re-applied on subsequent renders or when the screen regains focus. + const pendingLabel = route.params?.addLabelFilter; + useEffect(() => { + if (!pendingLabel) return; + setSelectedElementId(null); + setLabelFilters(prev => + prev.includes(pendingLabel) ? prev : [...prev, pendingLabel], + ); + navigation.setParams({addLabelFilter: undefined}); + }, [pendingLabel, navigation]); + useEffect(() => { let cancelled = false; viewportStore.load().then(v => { @@ -177,13 +202,24 @@ export function MapScreen() { return offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD; }, [position, viewportState]); + // Label filters apply on top of either mode below, narrowing the server-side + // result. When none are active the vars are omitted so the query is unchanged. + const labelVars = useMemo( + () => + labelFilters.length > 0 + ? {labels: labelFilters, labelsMatch: LabelMatchMode.All} + : undefined, + [labelFilters], + ); + // One query, two modes: when a trip filter is active fetch that trip's // elements; otherwise fetch by viewport bounds. Reusing the single hook keeps - // the element shape (and Apollo cache) identical across modes. + // the element shape (and Apollo cache) identical across modes. Label filters + // (if any) are layered onto whichever mode is active. const {data, loading: elementsLoading} = useElementsQuery( tripFilter - ? {variables: {tripId: tripFilter.id}} - : {skip: !bounds, variables: bounds ? {bounds} : undefined}, + ? {variables: {tripId: tripFilter.id, ...labelVars}} + : {skip: !bounds, variables: bounds ? {bounds, ...labelVars} : undefined}, ); // Accumulate elements across viewport fetches so pins persist while a new @@ -211,9 +247,13 @@ export function MapScreen() { return Array.from(elementsById.values()).filter(el => { if (!el.location) return false; const {longitude: lng, latitude: lat} = el.location; - return lng >= left && lng <= right && lat >= bottom && lat <= top; + if (lng < left || lng > right || lat < bottom || lat > top) return false; + // Apply the label filter client-side too (matching the query's ALL + // semantics) so pins accumulated before the filter was set — or that no + // longer match it — stop rendering without waiting for a refetch. + return labelFilters.every(label => el.labels.includes(label)); }); - }, [elementsById, bounds]); + }, [elementsById, bounds, labelFilters]); // Elements with a location for the active trip; drives both the markers and // the camera fit while filtering. @@ -271,6 +311,7 @@ export function MapScreen() { const handleSelectElement = useCallback( (element: SearchElement) => { setTripFilter(null); + setLabelFilters([]); if (element.location) { const {longitude, latitude} = element.location; // Seed the marker set so the pin is present immediately, before the @@ -303,6 +344,7 @@ export function MapScreen() { const handleSelectPlace = useCallback((place: SearchPlace) => { setTripFilter(null); + setLabelFilters([]); cameraRef.current?.flyTo({ center: [place.longitude, place.latitude], zoom: zoomForPlaceTypes(place.types), @@ -376,14 +418,15 @@ export function MapScreen() { } /> ) : null} - {tripFilter ? ( - setTripFilter(null)} - /> - ) : null} + setTripFilter(null)} + onClearLabel={label => + setLabelFilters(prev => prev.filter(l => l !== label)) + } + /> void; -}; - -/** - * Floating pill shown while the map is filtered to a single trip. Indicates the - * active filter and offers an `×` to clear it (restoring normal bounds-based - * element loading). - */ -export function TripFilterChip({icon, name, topOffset, onClear}: Props) { - return ( - - - {icon ? {icon} : null} - - {name} - - - × - - - - ); -} - -const styles = StyleSheet.create({ - wrap: { - position: 'absolute', - left: 0, - right: 0, - alignItems: 'center', - }, - chip: { - flexDirection: 'row', - alignItems: 'center', - maxWidth: '80%', - backgroundColor: '#1d6fe0', - borderRadius: 20, - paddingLeft: 14, - paddingRight: 6, - paddingVertical: 6, - shadowColor: '#000', - shadowOpacity: 0.2, - shadowRadius: 4, - shadowOffset: {width: 0, height: 2}, - elevation: 4, - }, - icon: { - fontSize: 15, - lineHeight: 18, - marginRight: 6, - }, - label: { - flexShrink: 1, - color: '#ffffff', - fontSize: 14, - fontWeight: '600', - }, - clearButton: { - width: 24, - height: 24, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - marginLeft: 8, - backgroundColor: 'rgba(255,255,255,0.25)', - }, - clearIcon: { - color: '#ffffff', - fontSize: 16, - lineHeight: 18, - }, -}); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index a47a072..73d2535 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -6,7 +6,9 @@ import type {NativeStackNavigationProp} from '@react-navigation/native-stack'; * fighting it for z-order as the old in-tree modal did. */ export type RootStackParamList = { - Map: undefined; + // `addLabelFilter` is set when returning to the map from a detail surface to + // add a tapped label to the active filters; the map consumes and clears it. + Map: {addLabelFilter?: string} | undefined; ElementDetail: {elementId: string}; ElementEdit: {elementId: string}; };