diff --git a/README.md b/README.md
index ef59618..c6c9ff9 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,10 @@
# TimeFlow
+## Frontend map setup
+Copy `frontend/.env.example` to `frontend/.env` and set
+`EXPO_PUBLIC_BAIDU_MAP_AK` to a Baidu Maps browser-side AK. Enable JavaScript
+API v4 for that AK in the Baidu Maps console and add the development and
+production web origins to its Referer whitelist. Native builds render the
+same JavaScript API in a WebView, so its configured `baseUrl`
+(`https://timeflow.local/`) must also be allowed by the AK configuration.
diff --git a/frontend/.env.example b/frontend/.env.example
index 877160b..591fdf0 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,2 +1,4 @@
# Android emulator: 10.0.2.2 reaches the host machine.
EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1
+# Baidu Maps browser-side AK with JavaScript API v4 enabled.
+EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak
diff --git a/frontend/src/components/BaiduMapWebView.ts b/frontend/src/components/BaiduMapWebView.ts
new file mode 100644
index 0000000..89cf0b4
--- /dev/null
+++ b/frontend/src/components/BaiduMapWebView.ts
@@ -0,0 +1,190 @@
+import type { MapLocation } from './MapPicker.types';
+
+export type BaiduMapBridgeMessage =
+ | { type: 'map-ready' }
+ | { message: string; type: 'map-error' }
+ | { latitude: number; longitude: number; type: 'selecting' }
+ | { location: MapLocation; type: 'selected' }
+ | { message: string; type: 'location-error' }
+ | { results: MapLocation[]; type: 'search-results' }
+ | { type: 'search-error' };
+
+function serializeForInlineScript(value: unknown) {
+ return JSON.stringify(value)
+ .replace(//g, '\\u003e')
+ .replace(/&/g, '\\u0026')
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029');
+}
+
+export function buildBaiduMapDocument(ak: string, initialLocation: MapLocation | null) {
+ const center = initialLocation ?? {
+ address: '上海市 · 默认地图中心',
+ latitude: 31.236305,
+ longitude: 121.480237,
+ };
+ const initialJson = serializeForInlineScript(initialLocation);
+ const centerJson = serializeForInlineScript(center);
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
diff --git a/frontend/src/components/MapPicker.native.tsx b/frontend/src/components/MapPicker.native.tsx
new file mode 100644
index 0000000..ed40812
--- /dev/null
+++ b/frontend/src/components/MapPicker.native.tsx
@@ -0,0 +1,180 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { View } from 'react-native';
+import { WebView, WebViewMessageEvent } from 'react-native-webview';
+
+import { BaiduMapBridgeMessage, buildBaiduMapDocument } from './BaiduMapWebView';
+import { BAIDU_MAP_AK, createCoordinateLocation } from './MapPicker.services';
+import { mapPickerStyles as styles } from './MapPicker.styles';
+import { MapPickerOverlay } from './MapPickerOverlay';
+import type { MapLocation, MapPickerProps } from './MapPicker.types';
+
+const SEARCH_TIMEOUT_MS = 8000;
+
+type PendingSearch = {
+ reject: (error: Error) => void;
+ resolve: (locations: MapLocation[]) => void;
+ timeout: ReturnType;
+};
+
+export function MapPicker({ initialLocation, onCancel, onConfirm }: MapPickerProps) {
+ const webViewRef = useRef(null);
+ const pendingSearchRef = useRef(null);
+ const [selection, setSelection] = useState(initialLocation);
+ const [locating, setLocating] = useState(false);
+ const [locationError, setLocationError] = useState(null);
+ const [mapReady, setMapReady] = useState(false);
+ const [mapError, setMapError] = useState(
+ BAIDU_MAP_AK ? null : '缺少百度地图浏览器端密钥,请完成地图服务配置。',
+ );
+ const document = useMemo(
+ () => buildBaiduMapDocument(BAIDU_MAP_AK, initialLocation),
+ [initialLocation],
+ );
+
+ useEffect(() => {
+ return () => {
+ if (pendingSearchRef.current) {
+ clearTimeout(pendingSearchRef.current.timeout);
+ pendingSearchRef.current = null;
+ }
+ };
+ }, []);
+
+ const failPendingSearch = useCallback((message: string) => {
+ const pending = pendingSearchRef.current;
+ if (!pending) return;
+ clearTimeout(pending.timeout);
+ pending.reject(new Error(message));
+ pendingSearchRef.current = null;
+ }, []);
+
+ const handleMessage = useCallback(
+ ({ nativeEvent }: WebViewMessageEvent) => {
+ let message: BaiduMapBridgeMessage;
+ try {
+ message = JSON.parse(nativeEvent.data) as BaiduMapBridgeMessage;
+ } catch {
+ return;
+ }
+
+ if (message.type === 'map-ready') {
+ setMapReady(true);
+ setMapError(null);
+ if (!initialLocation) {
+ webViewRef.current?.injectJavaScript('window.__timeflowLocate(); true;');
+ }
+ return;
+ }
+ if (message.type === 'map-error') {
+ setMapReady(false);
+ setMapError(message.message);
+ failPendingSearch(message.message);
+ return;
+ }
+ if (message.type === 'selecting') {
+ setLocationError(null);
+ setSelection(createCoordinateLocation(message.latitude, message.longitude));
+ setLocating(true);
+ return;
+ }
+ if (message.type === 'selected') {
+ setSelection(message.location);
+ setLocating(false);
+ setLocationError(null);
+ return;
+ }
+ if (message.type === 'location-error') {
+ setLocating(false);
+ setLocationError(message.message);
+ return;
+ }
+ if (message.type === 'search-results') {
+ const pending = pendingSearchRef.current;
+ if (!pending) return;
+ clearTimeout(pending.timeout);
+ pending.resolve(message.results);
+ pendingSearchRef.current = null;
+ return;
+ }
+ if (message.type === 'search-error') {
+ failPendingSearch('Baidu place search failed');
+ }
+ },
+ [failPendingSearch, initialLocation],
+ );
+
+ const searchLocations = useCallback(
+ (query: string) => {
+ return new Promise((resolve, reject) => {
+ if (!mapReady || !webViewRef.current) {
+ reject(new Error('Baidu map is not ready'));
+ return;
+ }
+
+ failPendingSearch('A newer search replaced this request');
+ const timeout = setTimeout(() => {
+ failPendingSearch('Baidu place search timed out');
+ }, SEARCH_TIMEOUT_MS);
+ pendingSearchRef.current = { reject, resolve, timeout };
+ webViewRef.current.injectJavaScript(
+ `window.__timeflowSearch(${JSON.stringify(query)}); true;`,
+ );
+ });
+ },
+ [failPendingSearch, mapReady],
+ );
+
+ const selectSearchResult = (location: MapLocation) => {
+ setLocationError(null);
+ setLocating(false);
+ setSelection(location);
+ webViewRef.current?.injectJavaScript(
+ `window.__timeflowSelect(${location.longitude}, ${location.latitude}); true;`,
+ );
+ };
+
+ const locateCurrentPosition = () => {
+ setLocationError(null);
+ setLocating(true);
+ webViewRef.current?.injectJavaScript('window.__timeflowLocate(); true;');
+ };
+
+ return (
+
+ {BAIDU_MAP_AK ? (
+ {
+ setMapReady(false);
+ setMapError('地图加载失败,请检查网络或百度地图密钥配置。');
+ }}
+ onMessage={handleMessage}
+ originWhitelist={['https://*']}
+ ref={webViewRef}
+ scrollEnabled={false}
+ setSupportMultipleWindows={false}
+ source={{ baseUrl: 'https://timeflow.local/', html: document }}
+ style={styles.mapCanvas}
+ />
+ ) : (
+
+ )}
+ selection && onConfirm(selection)}
+ onSearch={searchLocations}
+ onSelectSearchResult={selectSearchResult}
+ selection={selection}
+ />
+
+ );
+}
diff --git a/frontend/src/components/MapPicker.services.ts b/frontend/src/components/MapPicker.services.ts
new file mode 100644
index 0000000..633f016
--- /dev/null
+++ b/frontend/src/components/MapPicker.services.ts
@@ -0,0 +1,26 @@
+import type { MapLocation } from './MapPicker.types';
+
+export const BAIDU_MAP_AK = process.env.EXPO_PUBLIC_BAIDU_MAP_AK?.trim() ?? '';
+export const BAIDU_COORDINATE_SYSTEM = 'bd09ll' as const;
+
+export const SHANGHAI_CENTER: MapLocation = {
+ address: '上海市 · 默认地图中心',
+ latitude: 31.236305,
+ longitude: 121.480237,
+};
+
+export function coordinateAddress(latitude: number, longitude: number) {
+ return `百度地图选点 · ${latitude.toFixed(5)}, ${longitude.toFixed(5)}`;
+}
+
+export function createCoordinateLocation(latitude: number, longitude: number): MapLocation {
+ return {
+ address: coordinateAddress(latitude, longitude),
+ latitude,
+ longitude,
+ };
+}
+
+export function readablePoiAddress(title: string, address?: string) {
+ return address?.trim() ? `${title} · ${address.trim()}` : title;
+}
diff --git a/frontend/src/components/MapPicker.styles.ts b/frontend/src/components/MapPicker.styles.ts
new file mode 100644
index 0000000..9949cf7
--- /dev/null
+++ b/frontend/src/components/MapPicker.styles.ts
@@ -0,0 +1,150 @@
+import { StyleSheet } from 'react-native';
+
+import { colors } from '../constants/theme';
+
+export const mapPickerStyles = StyleSheet.create({
+ screen: { backgroundColor: '#D8E4DE', flex: 1 },
+ mapCanvas: { flex: 1 },
+ mapError: {
+ alignItems: 'center',
+ backgroundColor: 'rgba(247, 248, 245, 0.96)',
+ borderColor: colors.line,
+ borderRadius: 16,
+ borderWidth: 1,
+ left: 44,
+ paddingHorizontal: 20,
+ paddingVertical: 18,
+ position: 'absolute',
+ right: 44,
+ top: '38%',
+ zIndex: 900,
+ },
+ mapErrorTitle: { color: colors.ink, fontSize: 14, fontWeight: '800', marginTop: 10 },
+ mapErrorText: {
+ color: colors.sub,
+ fontSize: 11,
+ lineHeight: 17,
+ marginTop: 5,
+ textAlign: 'center',
+ },
+ topArea: {
+ left: 14,
+ position: 'absolute',
+ right: 14,
+ top: 14,
+ zIndex: 1000,
+ },
+ toolbar: { alignItems: 'center', flexDirection: 'row', gap: 8 },
+ searchBox: {
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderColor: colors.line,
+ borderRadius: 14,
+ borderWidth: 1,
+ elevation: 4,
+ flex: 1,
+ flexDirection: 'row',
+ height: 48,
+ paddingHorizontal: 12,
+ },
+ searchInput: {
+ borderWidth: 0,
+ color: colors.ink,
+ flex: 1,
+ fontSize: 13,
+ height: 46,
+ marginHorizontal: 8,
+ outlineColor: 'transparent',
+ outlineStyle: 'solid',
+ outlineWidth: 0,
+ paddingVertical: 0,
+ },
+ searchButton: {
+ alignItems: 'center',
+ height: 34,
+ justifyContent: 'center',
+ width: 30,
+ },
+ locateButton: {
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderColor: colors.line,
+ borderRadius: 14,
+ borderWidth: 1,
+ elevation: 4,
+ height: 48,
+ justifyContent: 'center',
+ width: 42,
+ },
+ locateButtonActive: { backgroundColor: colors.limeSoft },
+ searchResults: {
+ backgroundColor: colors.surface,
+ borderColor: colors.line,
+ borderRadius: 14,
+ borderWidth: 1,
+ marginLeft: 50,
+ marginTop: 7,
+ overflow: 'hidden',
+ },
+ searchResult: {
+ alignItems: 'center',
+ borderBottomColor: colors.line,
+ borderBottomWidth: 1,
+ flexDirection: 'row',
+ minHeight: 48,
+ paddingHorizontal: 12,
+ paddingVertical: 9,
+ },
+ searchResultLast: { borderBottomWidth: 0 },
+ searchResultText: { color: colors.ink, flex: 1, fontSize: 12, lineHeight: 17, marginLeft: 9 },
+ searchMessage: { color: colors.sub, fontSize: 11, padding: 14, textAlign: 'center' },
+ locationError: {
+ alignSelf: 'center',
+ backgroundColor: 'rgba(247, 248, 245, 0.96)',
+ borderColor: colors.line,
+ borderRadius: 11,
+ borderWidth: 1,
+ marginTop: 7,
+ paddingHorizontal: 10,
+ paddingVertical: 7,
+ },
+ locationErrorText: { color: colors.sub, fontSize: 11 },
+ selectionCard: {
+ backgroundColor: colors.surface,
+ borderColor: colors.line,
+ borderRadius: 18,
+ borderWidth: 1,
+ bottom: 16,
+ elevation: 6,
+ left: 14,
+ padding: 14,
+ position: 'absolute',
+ right: 14,
+ zIndex: 1000,
+ },
+ selectionHeading: { alignItems: 'center', flexDirection: 'row' },
+ selectionIcon: {
+ alignItems: 'center',
+ backgroundColor: colors.limeSoft,
+ borderRadius: 12,
+ height: 38,
+ justifyContent: 'center',
+ marginRight: 10,
+ width: 38,
+ },
+ selectionCopy: { flex: 1 },
+ selectionKicker: { color: '#728456', fontSize: 10, fontWeight: '800' },
+ selectionAddress: { color: colors.ink, fontSize: 13, lineHeight: 19, marginTop: 4 },
+ selectionHint: { color: colors.sub, fontSize: 11, lineHeight: 17, marginTop: 4 },
+ selectionMeta: { color: colors.muted, fontSize: 10, marginTop: 8 },
+ confirmButton: {
+ alignItems: 'center',
+ backgroundColor: colors.deep,
+ borderRadius: 12,
+ height: 48,
+ justifyContent: 'center',
+ marginTop: 12,
+ },
+ confirmButtonDisabled: { opacity: 0.38 },
+ confirmButtonText: { color: colors.surface, fontSize: 13, fontWeight: '800' },
+});
diff --git a/frontend/src/components/MapPicker.tsx b/frontend/src/components/MapPicker.tsx
new file mode 100644
index 0000000..798d2be
--- /dev/null
+++ b/frontend/src/components/MapPicker.tsx
@@ -0,0 +1 @@
+export { MapPicker } from './MapPicker.web';
diff --git a/frontend/src/components/MapPicker.types.ts b/frontend/src/components/MapPicker.types.ts
new file mode 100644
index 0000000..0f31c19
--- /dev/null
+++ b/frontend/src/components/MapPicker.types.ts
@@ -0,0 +1,12 @@
+export type MapLocation = {
+ address: string;
+ latitude: number;
+ longitude: number;
+ name?: string;
+};
+
+export type MapPickerProps = {
+ initialLocation: MapLocation | null;
+ onCancel: () => void;
+ onConfirm: (location: MapLocation) => void;
+};
diff --git a/frontend/src/components/MapPicker.web.tsx b/frontend/src/components/MapPicker.web.tsx
new file mode 100644
index 0000000..30e7489
--- /dev/null
+++ b/frontend/src/components/MapPicker.web.tsx
@@ -0,0 +1,262 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { load as loadBaiduMap } from '@baidumap/jsapi-loader';
+import { View } from 'react-native';
+
+import {
+ BAIDU_MAP_AK,
+ createCoordinateLocation,
+ readablePoiAddress,
+ SHANGHAI_CENTER,
+} from './MapPicker.services';
+import { mapPickerStyles as styles } from './MapPicker.styles';
+import { MapPickerOverlay } from './MapPickerOverlay';
+import type { MapLocation, MapPickerProps } from './MapPicker.types';
+
+const MAP_REQUEST_TIMEOUT_MS = 6000;
+const MAP_LOAD_TIMEOUT_MS = 5000;
+const MARKER_SVG = encodeURIComponent(
+ '',
+);
+
+export function MapPicker({ initialLocation, onCancel, onConfirm }: MapPickerProps) {
+ const mapHostRef = useRef(null);
+ const bmapRef = useRef(null);
+ const mapRef = useRef(null);
+ const markerRef = useRef(null);
+ const requestRef = useRef(0);
+ const [selection, setSelection] = useState(initialLocation);
+ const [locating, setLocating] = useState(false);
+ const [locationError, setLocationError] = useState(null);
+ const [mapReady, setMapReady] = useState(false);
+ const [mapError, setMapError] = useState(
+ BAIDU_MAP_AK ? null : '缺少百度地图浏览器端密钥,请完成地图服务配置。',
+ );
+
+ const moveMarker = useCallback((location: MapLocation) => {
+ const BMapApi = bmapRef.current;
+ const map = mapRef.current;
+ if (!BMapApi || !map) return;
+
+ const point = new BMapApi.Point(location.longitude, location.latitude);
+ if (markerRef.current) {
+ markerRef.current.setPosition(point);
+ return;
+ }
+
+ const icon = new BMapApi.Icon(
+ `data:image/svg+xml;charset=utf-8,${MARKER_SVG}`,
+ new BMapApi.Size(28, 28),
+ { anchor: new BMapApi.Size(14, 14) },
+ );
+ markerRef.current = new BMapApi.Marker(point, { icon });
+ map.addOverlay(markerRef.current);
+ }, []);
+
+ const selectCoordinates = useCallback(
+ (latitude: number, longitude: number) => {
+ const BMapApi = bmapRef.current;
+ if (!BMapApi) return;
+
+ const requestId = requestRef.current + 1;
+ requestRef.current = requestId;
+ const pendingLocation = createCoordinateLocation(latitude, longitude);
+ let completed = false;
+
+ setLocationError(null);
+ setSelection(pendingLocation);
+ moveMarker(pendingLocation);
+ setLocating(true);
+
+ const finish = (address?: string) => {
+ if (completed || requestRef.current !== requestId) return;
+ completed = true;
+ window.clearTimeout(timeout);
+ if (address?.trim()) setSelection({ ...pendingLocation, address: address.trim() });
+ setLocating(false);
+ };
+
+ const timeout = window.setTimeout(() => finish(), MAP_REQUEST_TIMEOUT_MS);
+ const geocoder = new BMapApi.Geocoder({ language: 'zh-CN' });
+ geocoder.getLocation(
+ new BMapApi.Point(longitude, latitude),
+ (result: BMap.GeocoderResult | null) => finish(result?.address),
+ );
+ },
+ [moveMarker],
+ );
+
+ const locateCurrentPosition = useCallback(() => {
+ const BMapApi = bmapRef.current;
+ const map = mapRef.current;
+ if (!BMapApi || !map) return;
+
+ setLocationError(null);
+ setLocating(true);
+ const geolocation = new BMapApi.Geolocation();
+ geolocation.getCurrentPosition(
+ (result) => {
+ if (geolocation.getStatus() !== 0 || !result?.point) {
+ setLocating(false);
+ setLocationError('无法获取当前位置,请允许定位权限后重试。');
+ return;
+ }
+
+ map.setCenter(result.point, { noAnimation: false });
+ map.setZoom(17, { noAnimation: false });
+ selectCoordinates(result.point.lat, result.point.lng);
+ },
+ { enableHighAccuracy: true },
+ );
+ }, [selectCoordinates]);
+
+ useEffect(() => {
+ const host = mapHostRef.current as unknown as HTMLElement | null;
+ if (!host) return;
+
+ if (!BAIDU_MAP_AK) return;
+
+ let disposed = false;
+ const loadTimeout = window.setTimeout(() => {
+ if (!disposed) {
+ setMapError('请在百度地图控制台为此 Key 开通 JavaScript API 服务后重试。');
+ }
+ }, MAP_LOAD_TIMEOUT_MS);
+
+ void loadBaiduMap({
+ ak: BAIDU_MAP_AK,
+ globalConfig: { coordType: 'bd09ll' },
+ timeout: 10000,
+ version: '4.0',
+ })
+ .then((namespace: typeof BMap) => {
+ if (disposed) return;
+ window.clearTimeout(loadTimeout);
+
+ const center = initialLocation ?? SHANGHAI_CENTER;
+ const point = new namespace.Point(center.longitude, center.latitude);
+ const map = new namespace.Map(host, {
+ center: point,
+ enablePinchZoom: true,
+ enableWheelZoom: true,
+ fixCenterWhenResize: true,
+ zoom: initialLocation ? 17 : 14,
+ });
+
+ bmapRef.current = namespace;
+ mapRef.current = map;
+ if (initialLocation) moveMarker(initialLocation);
+ map.addEventListener('click', (event) => {
+ selectCoordinates(event.point.lat, event.point.lng);
+ });
+ setMapError(null);
+ setMapReady(true);
+ if (!initialLocation) locateCurrentPosition();
+ })
+ .catch(() => {
+ if (!disposed) {
+ window.clearTimeout(loadTimeout);
+ setMapError('地图加载失败,请检查网络或百度地图密钥配置。');
+ }
+ });
+
+ return () => {
+ disposed = true;
+ window.clearTimeout(loadTimeout);
+ requestRef.current += 1;
+ markerRef.current = null;
+ const map = mapRef.current;
+ if (map) {
+ try {
+ const destroy = (map as unknown as { destroy?: () => void }).destroy;
+ if (typeof destroy === 'function') {
+ destroy.call(map);
+ } else {
+ (map as unknown as { clearOverlays?: () => void }).clearOverlays?.();
+ }
+ } catch {
+ // Baidu may have already torn down the map while the overlay closes.
+ }
+ }
+ mapRef.current = null;
+ bmapRef.current = null;
+ };
+ }, [initialLocation, locateCurrentPosition, moveMarker, selectCoordinates]);
+
+ const searchLocations = useCallback((query: string) => {
+ return new Promise((resolve, reject) => {
+ const BMapApi = bmapRef.current;
+ const map = mapRef.current;
+ if (!BMapApi || !map) {
+ reject(new Error('Baidu map is not ready'));
+ return;
+ }
+
+ let completed = false;
+ const finish = (locations?: MapLocation[]) => {
+ if (completed) return;
+ completed = true;
+ window.clearTimeout(timeout);
+ if (locations) resolve(locations);
+ else reject(new Error('Baidu place search failed'));
+ };
+ const timeout = window.setTimeout(() => finish(), MAP_REQUEST_TIMEOUT_MS);
+ const localSearch = new BMapApi.LocalSearch(map, {
+ onSearchComplete: (rawResults) => {
+ const result = Array.isArray(rawResults) ? rawResults[0] : rawResults;
+ if (!result) {
+ finish([]);
+ return;
+ }
+
+ const locations: MapLocation[] = [];
+ const count = Math.min(result.getCurrentNumPois(), 5);
+ for (let index = 0; index < count; index += 1) {
+ const poi = result.getPoi(index);
+ if (!poi?.point) continue;
+ locations.push({
+ address: readablePoiAddress(poi.title, poi.address),
+ latitude: poi.point.lat,
+ longitude: poi.point.lng,
+ });
+ }
+ finish(locations);
+ },
+ pageCapacity: 5,
+ renderOptions: { autoViewport: false },
+ });
+ localSearch.search(query);
+ });
+ }, []);
+
+ const selectSearchResult = (location: MapLocation) => {
+ const BMapApi = bmapRef.current;
+ const map = mapRef.current;
+ if (!BMapApi || !map) return;
+
+ setLocationError(null);
+ requestRef.current += 1;
+ setLocating(false);
+ setSelection(location);
+ moveMarker(location);
+ map.setCenter(new BMapApi.Point(location.longitude, location.latitude), { noAnimation: false });
+ map.setZoom(17, { noAnimation: false });
+ };
+
+ return (
+
+
+ selection && onConfirm(selection)}
+ onSearch={searchLocations}
+ onSelectSearchResult={selectSearchResult}
+ selection={selection}
+ />
+
+ );
+}
diff --git a/frontend/src/components/MapPickerOverlay.tsx b/frontend/src/components/MapPickerOverlay.tsx
new file mode 100644
index 0000000..555b72e
--- /dev/null
+++ b/frontend/src/components/MapPickerOverlay.tsx
@@ -0,0 +1,216 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { LocateFixed, MapPin, Search } from 'lucide-react-native';
+import { Pressable, Text, TextInput, View } from 'react-native';
+
+import { colors } from '../constants/theme';
+import { BackButton } from './BackButton';
+import { mapPickerStyles as styles } from './MapPicker.styles';
+import type { MapLocation } from './MapPicker.types';
+
+type MapPickerOverlayProps = {
+ mapError: string | null;
+ mapReady: boolean;
+ locating: boolean;
+ locationError: string | null;
+ onCancel: () => void;
+ onLocate: () => void;
+ onConfirm: () => void;
+ onSearch: (query: string) => Promise;
+ onSelectSearchResult: (location: MapLocation) => void;
+ selection: MapLocation | null;
+};
+
+export function MapPickerOverlay({
+ mapError,
+ mapReady,
+ locating,
+ locationError,
+ onCancel,
+ onLocate,
+ onConfirm,
+ onSearch,
+ onSelectSearchResult,
+ selection,
+}: MapPickerOverlayProps) {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [searching, setSearching] = useState(false);
+ const [searched, setSearched] = useState(false);
+ const [searchFailed, setSearchFailed] = useState(false);
+ const searchRequestRef = useRef(0);
+
+ const search = useCallback(
+ async (value: string) => {
+ const nextQuery = value.trim();
+ if (!nextQuery || !mapReady) return;
+
+ const requestId = searchRequestRef.current + 1;
+ searchRequestRef.current = requestId;
+ setSearching(true);
+ setSearched(false);
+ setSearchFailed(false);
+ try {
+ const nextResults = await onSearch(nextQuery);
+ if (requestId !== searchRequestRef.current) return;
+ setResults(nextResults);
+ setSearched(true);
+ } catch {
+ if (requestId !== searchRequestRef.current) return;
+ setResults([]);
+ setSearchFailed(true);
+ } finally {
+ if (requestId === searchRequestRef.current) setSearching(false);
+ }
+ },
+ [mapReady, onSearch],
+ );
+
+ useEffect(() => {
+ const nextQuery = query.trim();
+ if (!nextQuery || !mapReady) return;
+
+ const timer = setTimeout(() => {
+ void search(nextQuery);
+ }, 320);
+ return () => clearTimeout(timer);
+ }, [mapReady, query, search]);
+
+ const chooseResult = (location: MapLocation) => {
+ searchRequestRef.current += 1;
+ setSearching(false);
+ setQuery('');
+ setResults([]);
+ setSearched(false);
+ onSelectSearchResult(location);
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {
+ setQuery(value);
+ searchRequestRef.current += 1;
+ setSearching(false);
+ setSearchFailed(false);
+ setSearched(false);
+ if (!value.trim()) {
+ setResults([]);
+ }
+ }}
+ onSubmitEditing={() => void search(query)}
+ placeholder="搜索地点或地址"
+ placeholderTextColor="#909892"
+ returnKeyType="search"
+ style={styles.searchInput}
+ value={query}
+ />
+ void search(query)}
+ style={styles.searchButton}
+ >
+
+
+
+
+
+
+
+
+ {(searching || searchFailed || searched) && (
+
+ {searching ? (
+ 正在搜索...
+ ) : searchFailed ? (
+ 搜索暂时不可用,请直接在地图上选点
+ ) : results.length === 0 ? (
+ 没有找到相关地点
+ ) : (
+ results.map((result, index) => (
+ chooseResult(result)}
+ style={[
+ styles.searchResult,
+ index === results.length - 1 && styles.searchResultLast,
+ ]}
+ >
+
+
+ {result.address}
+
+
+ ))
+ )}
+
+ )}
+ {locationError ? (
+
+ {locationError}
+
+ ) : null}
+
+
+ {mapError && (
+
+
+ 百度地图暂时不可用
+ {mapError}
+
+ )}
+
+
+
+
+
+
+
+ 选中的地点
+ {selection ? (
+
+ {locating ? '正在获取详细地址...' : selection.address}
+
+ ) : locating ? (
+ 正在获取当前位置...
+ ) : (
+ 点击地图,或搜索后选择一个地点
+ )}
+
+
+ {selection && (
+
+ {selection.latitude.toFixed(5)}, {selection.longitude.toFixed(5)} · 百度地图 · BD-09
+
+ )}
+
+ 确认这个地点
+
+
+ >
+ );
+}
diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx
index b86246e..4fcf946 100644
--- a/frontend/src/screens/HomeScreen.tsx
+++ b/frontend/src/screens/HomeScreen.tsx
@@ -1,12 +1,54 @@
+import { useState } from 'react';
import { StatusBar } from 'expo-status-bar';
-import { StyleSheet, Text, View } from 'react-native';
+import { MapPin } from 'lucide-react-native';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
-import { colors, spacing } from '../constants/theme';
+import { MapPicker } from '../components/MapPicker';
+import type { MapLocation } from '../components/MapPicker.types';
+import { colors, radii, spacing } from '../constants/theme';
export function HomeScreen() {
+ const [location, setLocation] = useState(null);
+ const [pickingLocation, setPickingLocation] = useState(false);
+
+ if (pickingLocation) {
+ return (
+ setPickingLocation(false)}
+ onConfirm={(nextLocation) => {
+ setLocation(nextLocation);
+ setPickingLocation(false);
+ }}
+ />
+ );
+ }
+
return (
Timeflow
+ 为日程选择提醒地点
+ {location ? (
+
+
+
+
+ {location.address}
+
+
+ {location.latitude.toFixed(5)}, {location.longitude.toFixed(5)}
+
+
+
+ ) : null}
+ setPickingLocation(true)}
+ style={({ pressed }) => [styles.mapButton, pressed && styles.mapButtonPressed]}
+ >
+
+ {location ? '重新选择地点' : '打开地图选点'}
+
);
@@ -18,10 +60,63 @@ const styles = StyleSheet.create({
backgroundColor: colors.background,
flex: 1,
justifyContent: 'center',
+ padding: spacing.xl,
},
title: {
color: colors.text,
- fontSize: 24,
+ fontSize: 28,
+ fontWeight: '700',
+ },
+ subtitle: {
+ color: colors.sub,
+ fontSize: 15,
+ marginBottom: spacing.lg,
+ marginTop: spacing.xs,
+ },
+ locationSummary: {
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderColor: colors.line,
+ borderRadius: radii.sm,
+ borderWidth: 1,
+ flexDirection: 'row',
+ gap: spacing.sm,
+ marginBottom: spacing.md,
+ maxWidth: 420,
padding: spacing.md,
+ width: '100%',
+ },
+ locationCopy: {
+ flex: 1,
+ },
+ locationAddress: {
+ color: colors.ink,
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ locationCoordinates: {
+ color: colors.sub,
+ fontSize: 12,
+ marginTop: spacing.xs,
+ },
+ mapButton: {
+ alignItems: 'center',
+ backgroundColor: colors.lime,
+ borderRadius: radii.sm,
+ flexDirection: 'row',
+ gap: spacing.sm,
+ justifyContent: 'center',
+ maxWidth: 420,
+ minHeight: 48,
+ paddingHorizontal: spacing.lg,
+ width: '100%',
+ },
+ mapButtonPressed: {
+ opacity: 0.78,
+ },
+ mapButtonText: {
+ color: colors.deep,
+ fontSize: 15,
+ fontWeight: '700',
},
});