diff --git a/.blueprint/modules/floor-map.md b/.blueprint/modules/floor-map.md index 63c07c4..9545d7f 100644 --- a/.blueprint/modules/floor-map.md +++ b/.blueprint/modules/floor-map.md @@ -11,28 +11,28 @@ status: done ## 任务卡 -### 📋 平面图占位容器与坐标系 +### 🔵 平面图占位容器与坐标系 - status: done - complexity: M - files: apps/property-survey/src/features/map/FloorMap.tsx - depends: survey-app-shell - acceptance: 渲染一个带灰色背景的矩形占位区域作为平面图,使用 onLayout 获取实际像素尺寸并转换相对坐标(0~1),容器宽高比固定为 4:3,适配折叠/展开两种宽度 -### 📋 长按手势添加标记点 +### 🔵 长按手势添加标记点 - status: done - complexity: M - files: apps/property-survey/src/features/map/useMarkerGesture.ts - depends: floor-map - acceptance: 在平面图区域长按 500ms 后触发,将触点位置转换为相对坐标并调用 insertMarker 写入数据库,新标记点立即在地图上显示,不误触短按事件 -### 📋 标记点图标组件 +### 🔵 标记点图标组件 - status: done - complexity: M - files: apps/property-survey/src/features/map/MapMarker.tsx - depends: floor-map - acceptance: 在平面图上按相对坐标绝对定位渲染图钉图标,已有物品的标记点显示蓝色图钉 + 物品数量角标,空标记点显示灰色图钉,点击触发 onPress 回调 -### 📋 当前楼层标记点数据加载 +### 🔵 当前楼层标记点数据加载 - status: done - complexity: S - files: apps/property-survey/src/features/map/MarkerList.tsx diff --git a/.blueprint/modules/inventory-summary.md b/.blueprint/modules/inventory-summary.md index b891532..719d38c 100644 --- a/.blueprint/modules/inventory-summary.md +++ b/.blueprint/modules/inventory-summary.md @@ -11,21 +11,21 @@ status: done ## 任务卡 -### 📋 汇总数据查询 Hook +### 🔵 汇总数据查询 Hook - status: done - complexity: M - files: apps/property-survey/src/features/summary/useSummaryData.ts - depends: local-data-layer - acceptance: 查询所有 items JOIN markers JOIN photos,返回带楼层、坐标、物品名、数量、单价、备注、照片数的扁平列表;支持按 floor(1/2/3/全部)和 name 关键字过滤;数据写入后调用 refresh() 触发重新查询 -### 📋 汇总列表与筛选栏 +### 🔵 汇总列表与筛选栏 - status: done - complexity: M - files: apps/property-survey/src/features/summary/SummaryList.tsx, apps/property-survey/src/features/summary/FilterBar.tsx - depends: inventory-summary - acceptance: 列表每行显示楼层标签、物品名、数量、单价、备注1;FilterBar 提供楼层选择器(1F/2F/3F/全部)和物品名搜索框;滚动性能流畅(FlatList),空数据时显示引导文案 -### 📋 统计汇总卡片 +### 🔵 统计汇总卡片 - status: done - complexity: S - files: apps/property-survey/src/features/summary/SummaryStats.tsx diff --git a/.blueprint/modules/item-form.md b/.blueprint/modules/item-form.md index 3e19de2..012aa16 100644 --- a/.blueprint/modules/item-form.md +++ b/.blueprint/modules/item-form.md @@ -11,21 +11,21 @@ status: done ## 任务卡 -### 📋 底部抽屉/右栏容器 +### 🔵 底部抽屉/右栏容器 - status: done - complexity: M - files: apps/property-survey/src/features/item/ItemFormSheet.tsx - depends: survey-app-shell, floor-map - acceptance: 折叠时从屏幕底部滑出(BottomSheet),展开时在 FoldableLayout 右栏内渲染;接受 markerId 和 onClose 两个 prop;顶部显示标记点位置名称和所属楼层 -### 📋 物品字段表单 +### 🔵 物品字段表单 - status: done - complexity: M - files: apps/property-survey/src/features/item/ItemFields.tsx, apps/property-survey/src/features/item/useItemForm.ts - depends: item-form - acceptance: 包含物品名称(必填文本)、数量(数字键盘)、单价(数字键盘,可选)、备注1(多行文本)、备注2(多行文本)五个字段;保存时校验物品名称非空;useItemForm 管理字段状态和 insertItem / updateItem 调用 -### 📋 标记点下物品列表 +### 🔵 标记点下物品列表 - status: done - complexity: M - files: apps/property-survey/src/features/item/ItemList.tsx diff --git a/.blueprint/modules/local-data-layer.md b/.blueprint/modules/local-data-layer.md index 8294903..a960f3b 100644 --- a/.blueprint/modules/local-data-layer.md +++ b/.blueprint/modules/local-data-layer.md @@ -11,27 +11,27 @@ status: done ## 任务卡 -### 📋 SQLite 初始化与 Schema 建表 +### 🔵 SQLite 初始化与 Schema 建表 - status: done - complexity: M - files: apps/property-survey/src/db/database.ts, apps/property-survey/src/db/schema.ts - acceptance: App 启动时自动执行 CREATE TABLE IF NOT EXISTS,建立 markers / items / photos 三张表,重复启动不报错,字段与设计文档一致 -### 📋 标记点 CRUD 操作 +### 🔵 标记点 CRUD 操作 - status: done - complexity: M - files: apps/property-survey/src/db/markers.ts - depends: local-data-layer - acceptance: 提供 insertMarker / getMarkersByFloor / updateMarker / deleteMarker 四个函数,deleteMarker 级联删除关联 items 和 photos,所有函数返回类型安全的 Promise -### 📋 物品记录 CRUD 操作 +### 🔵 物品记录 CRUD 操作 - status: done - complexity: M - files: apps/property-survey/src/db/items.ts - depends: local-data-layer - acceptance: 提供 insertItem / getItemsByMarker / updateItem / deleteItem 四个函数,支持批量查询(传入 marker_id 数组),返回带关联照片数量的扩展类型 -### 📋 照片路径 CRUD 操作 +### 🔵 照片路径 CRUD 操作 - status: done - complexity: S - files: apps/property-survey/src/db/photos.ts diff --git a/.blueprint/modules/photo-capture.md b/.blueprint/modules/photo-capture.md index 7e9dbd1..d98d1ca 100644 --- a/.blueprint/modules/photo-capture.md +++ b/.blueprint/modules/photo-capture.md @@ -11,14 +11,14 @@ status: done ## 任务卡 -### 📋 摄像头调用与图片压缩 +### 🔵 摄像头调用与图片压缩 - status: done - complexity: M - files: apps/property-survey/src/features/photo/usePhotoCapture.ts - depends: local-data-layer - acceptance: 使用 expo-image-picker 提供「拍照」和「从相册选择」两种入口;拍摄/选择后自动压缩至最大宽度 1080px、JPEG quality 0.75;将压缩后的文件复制到 expo-file-system 的 App 专属目录;调用 insertPhoto 写入数据库;全流程有权限检查,拒绝权限时给出提示 -### 📋 照片网格预览与删除 +### 🔵 照片网格预览与删除 - status: done - complexity: M - files: apps/property-survey/src/features/photo/PhotoGrid.tsx, apps/property-survey/src/features/photo/PhotoPicker.tsx diff --git a/.blueprint/modules/survey-app-shell.md b/.blueprint/modules/survey-app-shell.md index 89efb8b..261177d 100644 --- a/.blueprint/modules/survey-app-shell.md +++ b/.blueprint/modules/survey-app-shell.md @@ -11,27 +11,27 @@ React Native (Expo SDK 52 + Expo Router) 应用骨架,实现 Z Fold 6 折叠/ ## 任务卡 -### 📋 Expo 应用骨架初始化 +### 🔵 Expo 应用骨架初始化 - status: done - complexity: M - files: apps/property-survey/app.json, apps/property-survey/package.json, apps/property-survey/app/_layout.tsx, apps/property-survey/app/index.tsx, apps/property-survey/app/summary.tsx - depends: local-data-layer - acceptance: `pnpm --filter property-survey start` 能在 Expo Go 或开发构建中启动,底部导航可在「登记」和「汇总」两个页面间切换,SQLite 数据库在启动时完成初始化 -### 📋 折叠屏状态感知 Hook +### 🔵 折叠屏状态感知 Hook - status: done - complexity: S - files: apps/property-survey/src/layout/useFoldState.ts - acceptance: useFoldState 返回 `{ isUnfolded: boolean, windowWidth: number }`,以 720px 为断点区分折叠/展开状态,useWindowDimensions 变化时自动更新,在模拟器宽度调整时可观察到状态切换 -### 📋 双栏/单栏自适应布局容器 +### 🔵 双栏/单栏自适应布局容器 - status: done - complexity: M - files: apps/property-survey/src/layout/FoldableLayout.tsx - depends: survey-app-shell - acceptance: 展开时渲染左右两栏(左侧 flexGrow:1 地图区,右侧 380px 详情区),折叠时只渲染左侧(地图全屏),接受 `left` 和 `right` 两个 ReactNode 插槽 -### 📋 楼层切换标签组件 +### 🔵 楼层切换标签组件 - status: done - complexity: S - files: apps/property-survey/src/layout/FloorTabs.tsx diff --git a/apps/property-survey/.expo/devices.json b/apps/property-survey/.expo/devices.json index aeffeae..02c3728 100644 --- a/apps/property-survey/.expo/devices.json +++ b/apps/property-survey/.expo/devices.json @@ -2,7 +2,7 @@ "devices": [ { "installationId": "626cc0bf-3d4a-47d1-9fd9-50b31e88db04", - "lastUsed": 1775741394159 + "lastUsed": 1775747314027 } ] } diff --git a/apps/property-survey/.expo/devices.json.zKddsK1M7Rzr_PY-rcWQEV1JU91kBHq1CYdryNzsK2Y b/apps/property-survey/.expo/devices.json.zKddsK1M7Rzr_PY-rcWQEV1JU91kBHq1CYdryNzsK2Y new file mode 100644 index 0000000..e69de29 diff --git a/apps/property-survey/.expo/types/router.d.ts b/apps/property-survey/.expo/types/router.d.ts index b815479..afe9ff4 100644 --- a/apps/property-survey/.expo/types/router.d.ts +++ b/apps/property-survey/.expo/types/router.d.ts @@ -6,9 +6,9 @@ export * from 'expo-router'; declare module 'expo-router' { export namespace ExpoRouter { export interface __routes { - hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; }; - hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/settings`; params?: Router.UnknownOutputParams; } | { pathname: `/summary`; params?: Router.UnknownOutputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; }; - href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/settings${`?${string}` | `#${string}` | ''}` | `/summary${`?${string}` | `#${string}` | ''}` | `/../src/features/item/ItemFormSheet${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/../src/features/item/ItemFormSheet`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; }; + hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; }; + hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/settings`; params?: Router.UnknownOutputParams; } | { pathname: `/summary`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; }; + href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/settings${`?${string}` | `#${string}` | ''}` | `/summary${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/summary`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; }; } } } diff --git a/apps/property-survey/app.json b/apps/property-survey/app.json index 19a13b7..48f5725 100644 --- a/apps/property-survey/app.json +++ b/apps/property-survey/app.json @@ -25,6 +25,14 @@ "package": "com.example.propertysurvey" }, "plugins": [ + [ + "expo-media-library", + { + "photosPermission": "允许访问相册以保存照片", + "savePhotosPermission": "允许保存照片到相册", + "isAccessMediaLocationEnabled": true + } + ], [ "expo-build-properties", { diff --git a/apps/property-survey/package.json b/apps/property-survey/package.json index f5635bd..e9448b8 100644 --- a/apps/property-survey/package.json +++ b/apps/property-survey/package.json @@ -20,6 +20,7 @@ "expo-file-system": "~18.0.0", "expo-image-picker": "~16.0.0", "expo-linking": "^7.0.5", + "expo-media-library": "~16.0.0", "expo-router": "~4.0.0", "expo-sharing": "~12.0.0", "expo-sqlite": "~15.1.0", diff --git a/apps/property-survey/src/features/item/ItemFields.tsx b/apps/property-survey/src/features/item/ItemFields.tsx index ea57f4a..8b86f05 100644 --- a/apps/property-survey/src/features/item/ItemFields.tsx +++ b/apps/property-survey/src/features/item/ItemFields.tsx @@ -20,7 +20,7 @@ interface ItemFieldsProps { } export function ItemFields({ hook }: ItemFieldsProps) { - const { form, setField, nameError, photoItemId } = hook + const { form, setField, nameError, photoItemId, save } = hook const { categories } = useCategories() return ( @@ -147,6 +147,13 @@ export function ItemFields({ hook }: ItemFieldsProps) { + + {/* 冻结在底部的保存按钮 */} + + + 保存 + + {/* 分隔线 */} @@ -226,7 +233,22 @@ const styles = StyleSheet.create({ errorText: { fontSize: 12, color: '#EF4444', marginLeft: 4 }, - bottomSpacer: { height: 24 }, + bottomSpacer: { height: 8 }, + + saveBar: { + paddingHorizontal: 14, + paddingVertical: 10, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#E5E7EB', + backgroundColor: '#fff', + }, + saveBtn: { + backgroundColor: '#10B981', + borderRadius: 8, + paddingVertical: 10, + alignItems: 'center', + }, + saveBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' }, // 分隔线 colDivider: { width: StyleSheet.hairlineWidth, backgroundColor: '#E5E7EB' }, diff --git a/apps/property-survey/src/features/item/ItemFormSheet.tsx b/apps/property-survey/src/features/item/ItemFormSheet.tsx index d9dadc6..a56b8e0 100644 --- a/apps/property-survey/src/features/item/ItemFormSheet.tsx +++ b/apps/property-survey/src/features/item/ItemFormSheet.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' -import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Alert, Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { Ionicons } from '@expo/vector-icons' import type { Marker } from '@repo/types' import { copyMarker, deleteMarker, updateMarker } from '../../db/markers' @@ -20,7 +20,22 @@ export function ItemFormSheet({ marker, onClose, onMarkerUpdated }: ItemFormShee const [refreshKey, setRefreshKey] = useState(0) const [currentColor, setCurrentColor] = useState(marker?.color ?? '#3B82F6') - const handleSaved = useCallback(() => setRefreshKey((k) => k + 1), []) + const toastOpacity = useRef(new Animated.Value(0)).current + + const showToast = useCallback(() => { + toastOpacity.setValue(0) + Animated.sequence([ + Animated.timing(toastOpacity, { toValue: 1, duration: 150, useNativeDriver: true }), + Animated.delay(1400), + Animated.timing(toastOpacity, { toValue: 0, duration: 300, useNativeDriver: true }), + ]).start() + }, [toastOpacity]) + + const handleSaved = useCallback(() => { + setRefreshKey((k) => k + 1) + onMarkerUpdated?.() + showToast() + }, [onMarkerUpdated, showToast]) const handleCopy = useCallback(async () => { if (!marker) return @@ -128,6 +143,11 @@ export function ItemFormSheet({ marker, onClose, onMarkerUpdated }: ItemFormShee + + + + 已保存 + ) } @@ -163,4 +183,18 @@ const styles = StyleSheet.create({ colorDot: { width: 24, height: 24, borderRadius: 12 }, colorDotSelected: { borderWidth: 3, borderColor: '#111827' }, formPane: { flex: 1 }, + + toast: { + position: 'absolute', + bottom: 64, + alignSelf: 'center', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: '#111827', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + }, + toastText: { color: '#fff', fontSize: 13, fontWeight: '600' }, }) diff --git a/apps/property-survey/src/features/item/useItemForm.ts b/apps/property-survey/src/features/item/useItemForm.ts index b1a7742..85a5343 100644 --- a/apps/property-survey/src/features/item/useItemForm.ts +++ b/apps/property-survey/src/features/item/useItemForm.ts @@ -29,15 +29,18 @@ export function useItemForm(markerId: number, onSaved: () => void) { const [editingId, setEditingId] = useState(null) const [photoItemId, setPhotoItemId] = useState(null) const [isDirty, setIsDirty] = useState(false) + const [nameError, setNameError] = useState('') function setField(key: K, value: FormState[K]) { setForm((prev) => ({ ...prev, [key]: value })) setIsDirty(true) + if (key === 'name') setNameError('') } function startEdit(item: Item) { setEditingId(item.id) setPhotoItemId(item.id) + setNameError('') setForm({ name: item.name, category: item.category, @@ -56,6 +59,7 @@ export function useItemForm(markerId: number, onSaved: () => void) { setPhotoItemId(null) setForm(DEFAULT_FORM) setIsDirty(false) + setNameError('') } // 自动保存:有名称且有改动时才执行,不弹错误 @@ -89,5 +93,37 @@ export function useItemForm(markerId: number, onSaved: () => void) { } }, [form, editingId, isDirty, markerId, onSaved]) - return { form, setField, isDirty, editingId, photoItemId, startEdit, resetForm, autoSave } + // 手动保存:有名称校验,无论 isDirty 均执行 + const save = useCallback(async () => { + if (!form.name.trim()) { + setNameError('物品名称不能为空') + return + } + const params: InsertItemParams = { + markerId, + name: form.name.trim(), + category: form.category, + quantity: Math.max(1, parseInt(form.quantity, 10) || 1), + unit: form.unit.trim(), + spec: form.spec.trim(), + estimatedValue: parseFloat(form.estimatedValue) || 0, + referencePrice: parseFloat(form.referencePrice) || 0, + remark: form.remark.trim(), + } + try { + if (editingId !== null) { + await updateItem(editingId, params) + } else { + const saved = await insertItem(params) + setEditingId(saved.id) + setPhotoItemId(saved.id) + } + setIsDirty(false) + onSaved() + } catch (err) { + console.error('保存物品失败:', err) + } + }, [form, editingId, markerId, onSaved]) + + return { form, setField, nameError, isDirty, editingId, photoItemId, startEdit, resetForm, autoSave, save } } diff --git a/apps/property-survey/src/features/map/MarkerList.tsx b/apps/property-survey/src/features/map/MarkerList.tsx index 58cdc56..14d8e66 100644 --- a/apps/property-survey/src/features/map/MarkerList.tsx +++ b/apps/property-survey/src/features/map/MarkerList.tsx @@ -146,7 +146,7 @@ const styles = StyleSheet.create({ // ── 侧栏 ────────────────────────────────────────── sidebar: { - width: 120, + width: 160, borderRightWidth: StyleSheet.hairlineWidth, borderRightColor: '#E5E7EB', backgroundColor: '#FAFAFA', diff --git a/apps/property-survey/src/features/photo/PhotoGrid.tsx b/apps/property-survey/src/features/photo/PhotoGrid.tsx index 46e2db4..55acf5a 100644 --- a/apps/property-survey/src/features/photo/PhotoGrid.tsx +++ b/apps/property-survey/src/features/photo/PhotoGrid.tsx @@ -10,6 +10,7 @@ import { View, } from 'react-native' import { Ionicons } from '@expo/vector-icons' +import * as MediaLibrary from 'expo-media-library' import type { Photo } from '@repo/types' import { deletePhoto, getPhotosByItem } from '../../db/photos' import { PhotoPicker } from './PhotoPicker' @@ -21,6 +22,7 @@ interface PhotoGridProps { export function PhotoGrid({ itemId }: PhotoGridProps) { const [photos, setPhotos] = useState([]) const [previewIndex, setPreviewIndex] = useState(null) + const [saving, setSaving] = useState(false) const load = useCallback(async () => { try { @@ -53,6 +55,27 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { ]) } + const handleSaveToAlbum = useCallback(async () => { + const currentPhoto = previewIndex !== null ? photos[previewIndex] : null + if (!currentPhoto) return + + setSaving(true) + try { + const { status } = await MediaLibrary.requestPermissionsAsync() + if (status !== 'granted') { + Alert.alert('权限不足', '请在系统设置中允许访问相册') + return + } + await MediaLibrary.saveToLibraryAsync(currentPhoto.filePath) + Alert.alert('已保存', '照片已成功保存到本地相册') + } catch (err) { + console.error('保存到相册失败:', err) + Alert.alert('保存失败', '无法保存照片,请重试') + } finally { + setSaving(false) + } + }, [previewIndex, photos]) + const currentPhoto = previewIndex !== null ? photos[previewIndex] : null return ( @@ -80,9 +103,20 @@ export function PhotoGrid({ itemId }: PhotoGridProps) { onRequestClose={() => setPreviewIndex(null)} > + {/* 关闭按钮 */} setPreviewIndex(null)}> + + {/* 保存到相册按钮 */} + + + + void) { } const pickOptions: ImagePicker.ImagePickerOptions = { - mediaTypes: ImagePicker.MediaTypeOptions.Images, + mediaTypes: ImagePicker.MediaType.Images, allowsEditing: false, quality: JPEG_QUALITY, exif: false, diff --git a/apps/property-survey/src/layout/FoldableLayout.tsx b/apps/property-survey/src/layout/FoldableLayout.tsx index 0f54816..5f33f90 100644 --- a/apps/property-survey/src/layout/FoldableLayout.tsx +++ b/apps/property-survey/src/layout/FoldableLayout.tsx @@ -1,7 +1,12 @@ -import { type ReactNode, useEffect, useRef } from 'react' -import { Animated, StyleSheet, View } from 'react-native' +import { type ReactNode, useRef } from 'react' +import { LayoutAnimation, Platform, StyleSheet, UIManager, View } from 'react-native' import { useFoldState } from './useFoldState' +// Android 需要手动开启 LayoutAnimation 支持 +if (Platform.OS === 'android') { + UIManager.setLayoutAnimationEnabledExperimental?.(true) +} + const DETAIL_PANEL_WIDTH = 260 interface FoldableLayoutProps { @@ -12,15 +17,13 @@ interface FoldableLayoutProps { export function FoldableLayout({ left, right, showDetail = false }: FoldableLayoutProps) { const { isUnfolded } = useFoldState() - const panelWidth = useRef(new Animated.Value(showDetail ? DETAIL_PANEL_WIDTH : 0)).current - useEffect(() => { - Animated.timing(panelWidth, { - toValue: showDetail ? DETAIL_PANEL_WIDTH : 0, - duration: 220, - useNativeDriver: false, - }).start() - }, [showDetail]) + // 在 props 变化引起的 re-render 之前触发原生布局动画 + const prevShowDetail = useRef(showDetail) + if (prevShowDetail.current !== showDetail) { + prevShowDetail.current = showDetail + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + } if (!isUnfolded) { return {left} @@ -29,25 +32,16 @@ export function FoldableLayout({ left, right, showDetail = false }: FoldableLayo return ( {left} - - {right} - + {showDetail && ( + {right} + )} ) } const styles = StyleSheet.create({ - single: { - flex: 1, - }, - dual: { - flex: 1, - flexDirection: 'row', - }, - mapPane: { - flexGrow: 1, - }, - detailPane: { - overflow: 'hidden', - }, + single: { flex: 1 }, + dual: { flex: 1, flexDirection: 'row' }, + mapPane: { flexGrow: 1 }, + detailPane: { width: DETAIL_PANEL_WIDTH }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e5c61b..3fa1f90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: expo-linking: specifier: ^7.0.5 version: 7.0.5(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) + expo-media-library: + specifier: ~16.0.0 + version: 16.0.5(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)) expo-router: specifier: ~4.0.0 version: 4.0.22(2up5cuuap7dudmj6aangd7ypjy) @@ -2146,6 +2149,11 @@ packages: peerDependencies: expo: '*' + expo-media-library@16.0.5: + resolution: {integrity: sha512-O9RUqBWgJVRF0mO6EiLSBFyfb5wR1/ZqovbT43V0TAo5sgcjrHRs+0NID/U6BWDRuiFeX2AU516JgNDutNUFSw==} + peerDependencies: + expo: '*' + expo-modules-autolinking@2.0.8: resolution: {integrity: sha512-DezgnEYFQYic8hKGhkbztBA3QUmSftjaNDIKNAtS2iGJmzCcNIkatjN2slFDSWjSTNo8gOvPQyMKfyHWFvLpOQ==} hasBin: true @@ -6787,6 +6795,10 @@ snapshots: transitivePeerDependencies: - supports-color + expo-media-library@16.0.5(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)): + dependencies: + expo: 52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1)))(react-native@0.76.3(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) + expo-modules-autolinking@2.0.8: dependencies: '@expo/spawn-async': 1.7.2