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', }, });