purewhiter--mobilegym
381 行
14 KiB
TypeScript
381 行
14 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { flushSync } from 'react-dom';
|
|
import {
|
|
IcClock,
|
|
IcClose,
|
|
IcLocation,
|
|
IcMic,
|
|
IcNavBack,
|
|
} from '../../res/icons';
|
|
import { useLocale } from '../../locale';
|
|
import { googleLangCode } from '../../utils/placeUtils';
|
|
import { useMapBackHandler } from '../../hooks/useMapBackHandler';
|
|
import { useMapGestures } from '../../hooks/useMapGestures';
|
|
import { useMapStrings } from '../../hooks/useMapStrings';
|
|
import {
|
|
useMapStore,
|
|
selectCurrentLocation,
|
|
selectGoogle,
|
|
type MapSearchHistoryEntry,
|
|
} from '../../state';
|
|
import { formatDistanceLabelMeters } from '../../utils/placeUtils';
|
|
import { getAutocompleteOffline } from '../../utils/offlinePlaceStore';
|
|
import { hasGoogleMapsApiKey } from '../../utils/googleMapsConfig';
|
|
import { KeyboardService } from '@/os/keyboard';
|
|
|
|
type RouteSide = 'origin' | 'destination';
|
|
|
|
type PlacePrediction = {
|
|
placeId: string;
|
|
mainText: string;
|
|
secondaryText: string;
|
|
distanceMeters?: number;
|
|
};
|
|
|
|
const AUTOCOMPLETE_DEBOUNCE_MS = 280;
|
|
|
|
export const RoutePointPickerOverlay: React.FC<{
|
|
mode: RouteSide;
|
|
initialQuery?: string;
|
|
searchHistory: MapSearchHistoryEntry[];
|
|
counterpart: { name: string } | null;
|
|
continuationDuration?: string | null;
|
|
viewportCenter?: { lat: number; lng: number } | null;
|
|
onClose: () => void;
|
|
onChooseOnMap: (side: RouteSide) => void;
|
|
onSelectPlace: (placeId: string, title: string, subtitle?: string) => void;
|
|
}> = ({
|
|
mode,
|
|
initialQuery = '',
|
|
searchHistory,
|
|
counterpart,
|
|
continuationDuration,
|
|
viewportCenter,
|
|
onClose,
|
|
onChooseOnMap,
|
|
onSelectPlace,
|
|
}) => {
|
|
const { bindTap } = useMapGestures();
|
|
const google = useMapStore(selectGoogle);
|
|
const currentLocation = useMapStore(selectCurrentLocation);
|
|
const locale = useLocale();
|
|
const s = useMapStrings();
|
|
|
|
const [query, setQuery] = useState(initialQuery);
|
|
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
|
const [predictions, setPredictions] = useState<PlacePrediction[]>([]);
|
|
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useMapBackHandler(
|
|
() => {
|
|
onClose();
|
|
return true;
|
|
},
|
|
{ priority: 700 },
|
|
);
|
|
|
|
useEffect(() => {
|
|
const t = window.setTimeout(() => setDebouncedQuery(query), AUTOCOMPLETE_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(t);
|
|
}, [query]);
|
|
|
|
useEffect(() => {
|
|
if (!google) return;
|
|
void google.maps.importLibrary('places');
|
|
}, [google]);
|
|
|
|
useEffect(() => {
|
|
const trimmedQuery = debouncedQuery.trim();
|
|
if (!trimmedQuery) {
|
|
setPredictions([]);
|
|
setSuggestionsLoading(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setSuggestionsLoading(true);
|
|
|
|
const run = async () => {
|
|
const offline = await getAutocompleteOffline({
|
|
input: trimmedQuery,
|
|
locale,
|
|
placesOnly: true,
|
|
});
|
|
if (cancelled) return;
|
|
if (offline.source === 'autocomplete_index') {
|
|
console.log(`[Map][离线] 路线选点提示: "${trimmedQuery}" 命中 autocomplete_index -> ${offline.predictions.length} 个地点`);
|
|
setPredictions(
|
|
offline.predictions.map((item) => ({
|
|
placeId: item.place_id,
|
|
mainText: item.main_text,
|
|
secondaryText: item.secondary_text,
|
|
distanceMeters: item.distance_meters,
|
|
})),
|
|
);
|
|
setSuggestionsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!google || !hasGoogleMapsApiKey()) {
|
|
const fallback = await getAutocompleteOffline({
|
|
input: trimmedQuery,
|
|
locale,
|
|
placesOnly: true,
|
|
allowSearchIndexFallback: true,
|
|
});
|
|
if (cancelled) return;
|
|
if (fallback.predictions.length > 0) {
|
|
console.log(`[Map][离线未命中] 路线选点提示: "${trimmedQuery}" 没有 autocomplete_index,Google key 不可用,使用 search_index 兜底 -> ${fallback.predictions.length} 个地点`);
|
|
setPredictions(
|
|
fallback.predictions.map((item) => ({
|
|
placeId: item.place_id,
|
|
mainText: item.main_text,
|
|
secondaryText: item.secondary_text,
|
|
distanceMeters: item.distance_meters,
|
|
})),
|
|
);
|
|
setSuggestionsLoading(false);
|
|
return;
|
|
}
|
|
console.log(`[Map][离线未命中] 路线选点提示: "${trimmedQuery}" 未命中 autocomplete_index/search_index,Google key 不可用`);
|
|
setPredictions([]);
|
|
setSuggestionsLoading(false);
|
|
return;
|
|
}
|
|
|
|
console.log(`[Map][在线] 路线选点提示: "${trimmedQuery}" 离线未命中,调用 Google Place Autocomplete`);
|
|
const service = new google.maps.places.AutocompleteService();
|
|
const biasLat = viewportCenter?.lat ?? currentLocation.latitude;
|
|
const biasLng = viewportCenter?.lng ?? currentLocation.longitude;
|
|
const locationForBias =
|
|
typeof biasLat === 'number' && typeof biasLng === 'number'
|
|
? new google.maps.LatLng(biasLat, biasLng)
|
|
: undefined;
|
|
|
|
const placeReq: google.maps.places.AutocompletionRequest = {
|
|
input: debouncedQuery,
|
|
language: googleLangCode(locale),
|
|
...(locationForBias
|
|
? { locationBias: { center: locationForBias, radius: 5000 }, origin: locationForBias }
|
|
: {}),
|
|
};
|
|
|
|
service.getPlacePredictions(placeReq, (results, status) => {
|
|
if (cancelled) return;
|
|
if (status !== google.maps.places.PlacesServiceStatus.OK || !results) {
|
|
console.log(`[Map][离线未命中] 路线选点提示: "${trimmedQuery}" 在线提示失败或无结果 (${status})`);
|
|
setPredictions([]);
|
|
setSuggestionsLoading(false);
|
|
return;
|
|
}
|
|
|
|
console.log(`[Map][在线] 路线选点提示: "${trimmedQuery}" -> ${results.length} 个地点`);
|
|
setPredictions(
|
|
results.map((item) => ({
|
|
placeId: item.place_id,
|
|
mainText: item.structured_formatting?.main_text ?? item.description ?? '',
|
|
secondaryText: item.structured_formatting?.secondary_text ?? '',
|
|
distanceMeters: item.distance_meters ?? undefined,
|
|
})),
|
|
);
|
|
setSuggestionsLoading(false);
|
|
});
|
|
};
|
|
|
|
void run();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [debouncedQuery, google, currentLocation.latitude, currentLocation.longitude, viewportCenter?.lat, viewportCenter?.lng, locale]);
|
|
|
|
const placeHistory = useMemo(
|
|
() =>
|
|
searchHistory.filter(
|
|
(item): item is MapSearchHistoryEntry & { kind: 'place'; placeId: string } =>
|
|
item.kind === 'place' && typeof item.placeId === 'string' && item.placeId.length > 0,
|
|
),
|
|
[searchHistory],
|
|
);
|
|
|
|
const placeholder = mode === 'origin' ? s.route_setup_select_start : s.route_setup_select_dest;
|
|
|
|
const handleSelect = (placeId: string, title: string, subtitle?: string) => {
|
|
KeyboardService.hide();
|
|
inputRef.current?.blur();
|
|
onSelectPlace(placeId, title, subtitle);
|
|
};
|
|
|
|
const showHistory = query.trim().length === 0;
|
|
|
|
return (
|
|
<div className="absolute inset-0 z-40 bg-app-surface pointer-events-auto">
|
|
<div className="flex h-full flex-col pt-10">
|
|
<div className="px-3 pb-2">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-gray-700 active:bg-gray-100"
|
|
{...bindTap<HTMLButtonElement>(
|
|
{ kind: 'action', id: 'routePicker.close' },
|
|
{ onTrigger: onClose },
|
|
)}
|
|
>
|
|
<IcNavBack size={24} />
|
|
</button>
|
|
<div className="flex h-12 flex-1 items-center gap-2 rounded-full bg-gray-100 px-4">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
autoFocus
|
|
value={query}
|
|
placeholder={placeholder}
|
|
className="flex-1 bg-transparent text-lg text-gray-900 outline-none placeholder:text-gray-500"
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
|
|
e.preventDefault();
|
|
if (predictions[0]) {
|
|
handleSelect(
|
|
predictions[0].placeId,
|
|
predictions[0].mainText,
|
|
predictions[0].secondaryText || undefined,
|
|
);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{query ? (
|
|
<button
|
|
type="button"
|
|
className="text-app-text-muted"
|
|
{...bindTap<HTMLButtonElement>(
|
|
{ kind: 'action', id: 'routePicker.clearQuery' },
|
|
{
|
|
onTrigger: () => {
|
|
flushSync(() => {
|
|
setQuery('');
|
|
setDebouncedQuery('');
|
|
});
|
|
inputRef.current?.focus();
|
|
},
|
|
},
|
|
)}
|
|
>
|
|
<IcClose size={22} />
|
|
</button>
|
|
) : (
|
|
<IcMic size={22} className="text-app-text-muted" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto pb-24">
|
|
{showHistory ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-4 px-4 py-4 text-left active:bg-gray-50"
|
|
{...bindTap<HTMLButtonElement>(
|
|
{ kind: 'action', id: 'routePicker.chooseOnMap' },
|
|
{
|
|
onTrigger: () => {
|
|
KeyboardService.hide();
|
|
inputRef.current?.blur();
|
|
onChooseOnMap(mode);
|
|
},
|
|
},
|
|
)}
|
|
>
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-app-text-muted">
|
|
<IcLocation size={20} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-lg text-gray-900">{s.select_on_map}</div>
|
|
</div>
|
|
</button>
|
|
|
|
{placeHistory.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className="flex w-full items-start gap-4 px-4 py-4 text-left active:bg-gray-50"
|
|
{...bindTap<HTMLButtonElement>(
|
|
{ kind: 'action', id: 'routePicker.history.select' },
|
|
{
|
|
onTrigger: () => handleSelect(item.placeId, item.text, item.subtitle),
|
|
},
|
|
)}
|
|
>
|
|
<div className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-app-text-muted">
|
|
<IcClock size={18} />
|
|
</div>
|
|
<div className="min-w-0 flex-1 border-b border-gray-100 pb-4">
|
|
<div className="truncate text-[17px] font-medium text-gray-900">{item.text}</div>
|
|
{item.subtitle ? (
|
|
<div className="truncate text-sm text-app-text-muted">{item.subtitle}</div>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="py-1">
|
|
{suggestionsLoading ? (
|
|
<div className="p-8 text-center text-app-text-muted">{s.search_searching}</div>
|
|
) : predictions.length > 0 ? (
|
|
predictions.map((item) => (
|
|
<button
|
|
key={item.placeId}
|
|
type="button"
|
|
className="flex w-full items-start gap-4 px-4 py-4 text-left active:bg-gray-50"
|
|
{...bindTap<HTMLButtonElement>(
|
|
{ kind: 'action', id: 'routePicker.prediction.select' },
|
|
{
|
|
onTrigger: () => handleSelect(
|
|
item.placeId,
|
|
item.mainText,
|
|
item.secondaryText || undefined,
|
|
),
|
|
},
|
|
)}
|
|
>
|
|
<div className="mt-1 flex min-w-[52px] shrink-0 flex-col items-center text-app-text-muted">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100">
|
|
<IcLocation size={17} />
|
|
</div>
|
|
<span className="mt-1 text-xs text-gray-400">
|
|
{formatDistanceLabelMeters(item.distanceMeters, locale)}
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0 flex-1 border-b border-gray-100 pb-4">
|
|
<div className="truncate text-[17px] font-medium text-gray-900">{item.mainText}</div>
|
|
<div className="truncate text-sm text-app-text-muted">{item.secondaryText}</div>
|
|
</div>
|
|
<div className="pt-1 text-gray-500">
|
|
<IcNavBack size={18} className="rotate-180 -rotate-45" />
|
|
</div>
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="p-8 text-center text-app-text-muted">{s.search_no_results}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showHistory && counterpart ? (
|
|
<div className="absolute inset-x-0 bottom-0 border-t border-gray-100 bg-app-surface px-4 py-4 shadow-up">
|
|
<div className="text-[17px] font-medium text-gray-900">{s.route_continue_to}{counterpart.name}</div>
|
|
{continuationDuration ? (
|
|
<div className="mt-1 text-sm text-app-text-muted">{continuationDuration}</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|