- 新增「设置」Tab,支持物品分类的增删改(数据库持久化) - 物品分类由硬编码枚举迁移为动态分类,通过 CategoriesContext 全局共享 - items 表新增 unit(单位)、spec(规格)字段,附带旧库迁移 - markers 表 CHECK 约束扩展支持第 4 楼(楼顶),附带重建表迁移 - SVG 地图渲染:overdraw 预渲染提升清晰度,嵌入图片改用原生 Image 渲染 - FoldableLayout 改为 showDetail prop 控制动画宽度 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
180 lines
4.6 KiB
TypeScript
180 lines
4.6 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { StyleSheet, Text, View } from 'react-native'
|
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
import Animated, {
|
|
cancelAnimation,
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withRepeat,
|
|
withSequence,
|
|
withSpring,
|
|
withTiming,
|
|
type SharedValue,
|
|
} from 'react-native-reanimated'
|
|
import { Ionicons } from '@expo/vector-icons'
|
|
import type { Marker } from '@repo/types'
|
|
import type { MapSize } from './FloorMap'
|
|
|
|
const ICON_SIZE = 20
|
|
const BADGE_OFFSET = -5
|
|
const RING_SIZE = 38
|
|
|
|
interface MapMarkerProps {
|
|
marker: Marker
|
|
mapSize: MapSize
|
|
mapScale: SharedValue<number>
|
|
itemCount?: number
|
|
isSelected?: boolean
|
|
onPress: () => void
|
|
onDragEnd: (x: number, y: number) => void
|
|
}
|
|
|
|
export function MapMarker({ marker, mapSize, mapScale, itemCount = 0, isSelected = false, onPress, onDragEnd }: MapMarkerProps) {
|
|
const left = marker.x * mapSize.width - ICON_SIZE / 2
|
|
const top = marker.y * mapSize.height - ICON_SIZE
|
|
|
|
// 拖动状态
|
|
const offsetX = useSharedValue(0)
|
|
const offsetY = useSharedValue(0)
|
|
const dragScale = useSharedValue(1)
|
|
const isDragging = useSharedValue(false)
|
|
|
|
// 对焦脉冲动画
|
|
const pulseScale = useSharedValue(1)
|
|
const pulseOpacity = useSharedValue(0)
|
|
|
|
useEffect(() => {
|
|
if (isSelected) {
|
|
// 每次选中从初始状态重新开始脉冲
|
|
pulseScale.value = withRepeat(
|
|
withSequence(
|
|
withTiming(1, { duration: 0 }),
|
|
withTiming(2.4, { duration: 1100 }),
|
|
),
|
|
-1,
|
|
false,
|
|
)
|
|
pulseOpacity.value = withRepeat(
|
|
withSequence(
|
|
withTiming(0.7, { duration: 0 }),
|
|
withTiming(0, { duration: 1100 }),
|
|
),
|
|
-1,
|
|
false,
|
|
)
|
|
} else {
|
|
cancelAnimation(pulseScale)
|
|
cancelAnimation(pulseOpacity)
|
|
pulseOpacity.value = withTiming(0, { duration: 150 })
|
|
}
|
|
}, [isSelected])
|
|
|
|
const tap = Gesture.Tap().onEnd(() => {
|
|
runOnJS(onPress)()
|
|
})
|
|
|
|
const pan = Gesture.Pan()
|
|
.activateAfterLongPress(400)
|
|
.onStart(() => {
|
|
dragScale.value = withSpring(1.3)
|
|
isDragging.value = true
|
|
})
|
|
.onUpdate((e) => {
|
|
offsetX.value = e.translationX
|
|
offsetY.value = e.translationY
|
|
})
|
|
.onEnd((e) => {
|
|
const s = mapScale.value
|
|
const newX = Math.max(0, Math.min(1, marker.x + e.translationX / (mapSize.width * s)))
|
|
const newY = Math.max(0, Math.min(1, marker.y + e.translationY / (mapSize.height * s)))
|
|
runOnJS(onDragEnd)(newX, newY)
|
|
})
|
|
.onFinalize(() => {
|
|
dragScale.value = withSpring(1)
|
|
isDragging.value = false
|
|
offsetX.value = 0
|
|
offsetY.value = 0
|
|
})
|
|
|
|
const gesture = Gesture.Race(tap, pan)
|
|
|
|
const containerStyle = useAnimatedStyle(() => ({
|
|
transform: [
|
|
{ translateX: offsetX.value },
|
|
{ translateY: offsetY.value },
|
|
{ scale: dragScale.value / mapScale.value },
|
|
],
|
|
zIndex: isDragging.value ? 999 : isSelected ? 10 : 1,
|
|
shadowColor: '#000',
|
|
shadowOpacity: isDragging.value ? 0.3 : 0,
|
|
shadowRadius: isDragging.value ? 8 : 0,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
elevation: isDragging.value ? 8 : 0,
|
|
}))
|
|
|
|
const pulseStyle = useAnimatedStyle(() => ({
|
|
transform: [{ scale: pulseScale.value }],
|
|
opacity: pulseOpacity.value,
|
|
}))
|
|
|
|
const markerColor = marker.color ?? '#3B82F6'
|
|
|
|
return (
|
|
<GestureDetector gesture={gesture}>
|
|
<Animated.View style={[styles.container, { left, top }, containerStyle]}>
|
|
{/* 脉冲光环(选中时持续扩散) */}
|
|
<Animated.View
|
|
style={[
|
|
styles.pulseRing,
|
|
{ borderColor: markerColor },
|
|
pulseStyle,
|
|
]}
|
|
/>
|
|
|
|
<Ionicons name="location" size={ICON_SIZE} color={markerColor} />
|
|
|
|
{itemCount > 0 && (
|
|
<View style={styles.badge}>
|
|
<Text style={styles.badgeText}>{itemCount}</Text>
|
|
</View>
|
|
)}
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
)
|
|
}
|
|
|
|
const RING_OFFSET = -(RING_SIZE - ICON_SIZE) / 2 // 居中到图标中心
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
position: 'absolute',
|
|
},
|
|
pulseRing: {
|
|
position: 'absolute',
|
|
width: RING_SIZE,
|
|
height: RING_SIZE,
|
|
borderRadius: RING_SIZE / 2,
|
|
borderWidth: 2,
|
|
top: RING_OFFSET,
|
|
left: RING_OFFSET,
|
|
},
|
|
badge: {
|
|
position: 'absolute',
|
|
top: BADGE_OFFSET,
|
|
right: BADGE_OFFSET,
|
|
minWidth: 12,
|
|
height: 12,
|
|
borderRadius: 6,
|
|
backgroundColor: '#EF4444',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 2,
|
|
},
|
|
badgeText: {
|
|
color: '#fff',
|
|
fontSize: 8,
|
|
fontWeight: '700',
|
|
},
|
|
})
|