diff --git a/code/app/.workbuddy/check_bg.py b/code/app/.workbuddy/check_bg.py new file mode 100644 index 00000000..d518ef16 --- /dev/null +++ b/code/app/.workbuddy/check_bg.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +import sys +import io +import urllib.request +from PIL import Image + +URL = ("http://118.89.70.199:9001/test/finance-category-default/income/hongbao.webp" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD" + "&X-Amz-Credential=54WPUR104SA9X00EKWS3%2F20260629%2Fus-east-1%2Fs3%2Faws4_request" + "&X-Amz-Date=20260629T105003Z&X-Amz-Expires=86400" + "&X-Amz-Security-Token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJleHAiOjE3ODI3NzMyMDUsInBhcmVudCI6Im9uZWFvIn0.zkP6L1zg_LPy1UuK-3-VT6LEWuhLNickKWTWDly_PzJCnwIRPS4naSZE9Lx84HtXPVVhxaZguHXl65AXjROJZA" + "&X-Amz-Signature=b763181ad8bf74deb2ac55ac18402d26a91075088c9c109cfd3a4da6cca7b38f" + "&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject") + +out_path = r"D:\workspace\code\app\.workbuddy\hongbao.webp" + +req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"}) +with urllib.request.urlopen(req, timeout=60) as resp: + data = resp.read() +print("Downloaded bytes:", len(data)) +with open(out_path, "wb") as f: + f.write(data) + +img = Image.open(out_path) +print("Format:", img.format) +print("Size:", img.size) +print("Mode:", img.mode) + +# Force load pixel data +img.load() + +has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info) +print("Has alpha channel:", has_alpha) + +if img.mode == "P": + # Convert palette to RGBA to inspect real pixels + rgba = img.convert("RGBA") +elif img.mode != "RGBA": + rgba = img.convert("RGBA") +else: + rgba = img + +w, h = rgba.size +px = rgba.load() + +def info(x, y): + r, g, b, a = px[x, y] + return f"({x},{y}) RGBA=({r},{g},{b},{a})" + +print("\n--- Corner pixels ---") +print("TL", info(0, 0)) +print("TR", info(w - 1, 0)) +print("BL", info(0, h - 1)) +print("BR", info(w - 1, h - 1)) + +print("\n--- Edge midpoints ---") +print("Top-mid", info(w // 2, 0)) +print("Bottom-mid", info(w // 2, h - 1)) +print("Left-mid", info(0, h // 2)) +print("Right-mid", info(w - 1, h // 2)) + +# Statistics over all pixels +total = w * h +transparent = 0 # alpha == 0 +near_transparent = 0 # alpha < 255 and > 0 +fully_opaque = 0 # alpha == 255 +white_pixels = 0 # opaque & RGB ~ (255,255,255) +near_white_bg = 0 # alpha==0 OR rgb near white + +for y in range(h): + for x in range(w): + r, g, b, a = px[x, y] + if a == 0: + transparent += 1 + elif a < 255: + near_transparent += 1 + else: + fully_opaque += 1 + if r > 245 and g > 245 and b > 245: + white_pixels += 1 + +print("\n--- Global statistics ---") +print("Total pixels:", total) +print("Fully transparent (a==0):", transparent, f"({100*transparent/total:.2f}%)") +print("Semi-transparent (0= w - t or y >= h - t + +border_total = 0 +border_trans = 0 +border_white = 0 +for y in range(h): + for x in range(w): + if is_border(x, y, w, h): + border_total += 1 + r, g, b, a = px[x, y] + if a == 0: + border_trans += 1 + elif r > 245 and g > 245 and b > 245: + border_white += 1 + +print("\n--- Border ring (outer 2px) ---") +print("Border pixels:", border_total) +print("Border transparent:", border_trans, f"({100*border_trans/border_total:.2f}%)") +print("Border near-white opaque:", border_white, f"({100*border_white/border_total:.2f}%)") + +print("\n=== CONCLUSION ===") +if border_trans / border_total > 0.5: + print("BACKGROUND IS TRANSPARENT (alpha channel, mostly a==0 at edges)") +elif border_white / border_total > 0.5: + print("BACKGROUND IS WHITE (opaque near-white pixels at edges)") +else: + print("Background is neither clearly transparent nor white - inspect manually") diff --git a/code/app/.workbuddy/hongbao.webp b/code/app/.workbuddy/hongbao.webp new file mode 100644 index 00000000..274a50e6 Binary files /dev/null and b/code/app/.workbuddy/hongbao.webp differ diff --git a/code/app/.workbuddy/hongbao_bg_compare.png b/code/app/.workbuddy/hongbao_bg_compare.png new file mode 100644 index 00000000..4548e7cc Binary files /dev/null and b/code/app/.workbuddy/hongbao_bg_compare.png differ diff --git a/code/app/.workbuddy/make_compare.py b/code/app/.workbuddy/make_compare.py new file mode 100644 index 00000000..d2e32194 --- /dev/null +++ b/code/app/.workbuddy/make_compare.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from PIL import Image, ImageDraw + +src = r"D:\workspace\code\app\.workbuddy\hongbao.webp" +img = Image.open(src).convert("RGBA") +w, h = img.size +tile = 16 + +# Panel 1: checkerboard background (shows transparency) +def make_checker(width, height, t): + cb = Image.new("RGB", (width, height), (255, 255, 255)) + d = ImageDraw.Draw(cb) + for y in range(0, height, t): + for x in range(0, width, t): + if (x // t + y // t) % 2 == 0: + d.rectangle([x, y, x + t - 1, y + t - 1], fill=(204, 204, 204)) + return cb + +checker = make_checker(w, h, tile).convert("RGBA") +panel1 = Image.alpha_composite(checker, img).convert("RGB") + +# Panel 2: white background +white_bg = Image.new("RGBA", (w, h), (255, 255, 255, 255)) +panel2 = Image.alpha_composite(white_bg, img).convert("RGB") + +# Panel 3: alpha channel as grayscale (white = opaque, black = transparent) +alpha = img.split()[3] +panel3 = Image.merge("RGB", (alpha, alpha, alpha)) + +# Combine with labels +gap = 10 +label_h = 28 +total_w = w * 3 + gap * 2 +total_h = h + label_h +canvas = Image.new("RGB", (total_w, total_h), (245, 245, 245)) +canvas.paste(panel1, (0, label_h)) +canvas.paste(panel2, (w + gap, label_h)) +canvas.paste(panel3, (w * 2 + gap * 2, label_h)) + +d = ImageDraw.Draw(canvas) +d.text((4, 6), "Transparent bg (checker)", fill=(0, 0, 0)) +d.text((w + gap + 4, 6), "White bg", fill=(0, 0, 0)) +d.text((w * 2 + gap * 2 + 4, 6), "Alpha channel", fill=(0, 0, 0)) + +out = r"D:\workspace\code\app\.workbuddy\hongbao_bg_compare.png" +canvas.save(out) +print("Saved:", out) diff --git a/code/app/.workbuddy/memory/2026-06-29.md b/code/app/.workbuddy/memory/2026-06-29.md new file mode 100644 index 00000000..06ba128e --- /dev/null +++ b/code/app/.workbuddy/memory/2026-06-29.md @@ -0,0 +1,22 @@ +# 2026-06-29 工作日志 + +## finance_default 图标背景色检查 +- 检查 `finance-category-default/income/hongbao.webp` 背景是否透明 +- 使用 Python + Pillow 下载并分析像素 alpha 通道 +- 结果:**背景为透明色**(RGBA 模式,外层 2px 边缘 100% 完全透明 a==0,全局 38.15% 像素完全透明) +- 图片尺寸 256x256,webp 格式 +- 生成对比图 `hongbao_bg_compare.png`(透明棋盘格 / 白色背景 / alpha 通道三视图) +- 分析脚本:`.workbuddy/check_bg.py`、`.workbuddy/make_compare.py` + +## finance/add/index.tsx 修复 +- 修复第 302 行乱码:`璁拌处` → `记账`(UTF-8 被误解释为 GBK) + +## FinanceCategoryIcon 背景色修复 +- 移除 `FinanceCategoryIcon` 内部 View 的 `backgroundColor: "#F3F4F6"`(DEFAULT_BG) +- 根因:该背景色遮挡了外层选中态 `bg-accent` 绿色,导致图片透明区域显示灰色而非绿色 +- 选中态背景由外层 `CategoryItem` 的 `bg-accent` / `bg-gray-100` 控制 + +## 记账 add 页面 BottomSheet 布局调整 +- 分类 icon+名称与日期合并到同一行(左右分布) +- 金额移除 Label,居中放大(text-4xl) +- 备注保持不变 diff --git a/code/app/app-rn/src/app/(main)/chat/history.tsx b/code/app/app-rn/src/app/(main)/chat/history.tsx index f74da944..0ec58f76 100644 --- a/code/app/app-rn/src/app/(main)/chat/history.tsx +++ b/code/app/app-rn/src/app/(main)/chat/history.tsx @@ -259,8 +259,7 @@ export default function ChatHistoryPage() { - - + 筛选聊天记录 应用 - diff --git a/code/app/app-rn/src/app/(main)/family/settings.tsx b/code/app/app-rn/src/app/(main)/family/settings.tsx index 95d50082..028c5835 100644 --- a/code/app/app-rn/src/app/(main)/family/settings.tsx +++ b/code/app/app-rn/src/app/(main)/family/settings.tsx @@ -551,8 +551,7 @@ export default function HomeSettings() { - - + 保存 - diff --git a/code/app/app-rn/src/app/(main)/family/user.tsx b/code/app/app-rn/src/app/(main)/family/user.tsx index 6e3a53cb..48a9367e 100644 --- a/code/app/app-rn/src/app/(main)/family/user.tsx +++ b/code/app/app-rn/src/app/(main)/family/user.tsx @@ -723,8 +723,7 @@ export default function UserSetting() { - - + 保存 - @@ -747,8 +745,7 @@ export default function UserSetting() { - - + 保存 - diff --git a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx index b2ee6b54..9c9d091a 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx @@ -485,8 +485,7 @@ export default function ClothingHome() { - - + 筛选衣服 应用 - diff --git a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx index 14faab7a..c1d009f1 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx @@ -659,9 +659,9 @@ export default function ClothingMetadataManage() { > - + {form && ( - + <> {renderFormTitle()} @@ -743,7 +743,7 @@ export default function ClothingMetadataManage() { 保存 - + )} diff --git a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx index e79ab54f..f264c0d0 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx @@ -625,8 +625,7 @@ export default function FinanceAnalysis() { - - + 筛选分析 应用 - diff --git a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx index b19bc963..261e6377 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; -import { Button, Label, Menu, TextField } from "heroui-native"; +import { Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { Button, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; import { router, useFocusEffect } from "expo-router"; import { @@ -15,6 +15,7 @@ import { } from "lucide-react-native"; import dayjs from "dayjs"; import LayoutView from "@/components/layout/view"; +import { buildFileUrl } from "@/utils/file"; import EmptyState from "@/components/EmptyStatus"; import DateTimeRange from "@/components/DateTimeRange"; import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; @@ -46,7 +47,7 @@ const moneyFormatter = new Intl.NumberFormat("zh-CN", { }); function formatMoney(value: number) { - return moneyFormatter.format(value || 0); + return moneyFormatter.format(Math.abs(value)); } function createDefaultFilter(): FilterState { @@ -73,7 +74,6 @@ export default function Home() { const [showDateRange, setShowDateRange] = useState(false); const [bills, setBills] = useState([]); const [activeBill, setActiveBill] = useState(null); - const showActionMenu = activeBill != null; const selectedCategory = useMemo( () => categories.find((item) => item.id === filter.categoryId) ?? null, @@ -167,6 +167,7 @@ export default function Home() { " c.type as category_type,\n" + " c.icon_file_key as category_icon_file_key,\n" + " coalesce(m.nickname, u.name) as member_nickname,\n" + + " u.avatar as member_avatar,\n" + " coalesce(um.nickname, uu.name) as update_member_nickname\n" + "from b_finance f\n" + "left join b_finance_category c on c.id = f.category_id\n" + @@ -287,7 +288,12 @@ export default function Home() { - {filter.startDate} 至 {filter.endDate} + {dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")} + + + {dayjs(filter.startDate).year() !== dayjs(filter.endDate).year() + ? `${dayjs(filter.startDate).format("YYYY")} / ${dayjs(filter.endDate).format("YYYY")}` + : dayjs(filter.startDate).format("YYYY年")} @@ -314,15 +320,21 @@ export default function Home() { - 支出合计 - - ¥{formatMoney(totals.expense)} + 支出 + + {formatMoney(totals.expense)} - 收入合计 - - ¥{formatMoney(totals.income)} + 收入 + + {formatMoney(totals.income)} + + + + 结余 + = 0 ? "text-emerald-600" : "text-rose-500"}`}> + {formatMoney(totals.income - totals.expense)} @@ -341,8 +353,8 @@ export default function Home() { {dayjs(billSection.title).format("YYYY年MM月DD日")} - -¥{formatMoney(billSection.expenseTotal)} - +¥{formatMoney(billSection.incomeTotal)} + -{formatMoney(billSection.expenseTotal)} + +{formatMoney(billSection.incomeTotal)} @@ -350,47 +362,63 @@ export default function Home() { }, []); const renderItem = useCallback( - ({ item }: { item: FinanceBill }) => { + ({ item, index, section }: { item: FinanceBill; index: number; section: { data: FinanceBill[] } }) => { const isIncome = item.category_type === FinanceType.Income; const isOwnRecord = String(item.create_by ?? "") === String(userId ?? ""); const remark = item.remark?.trim(); + const isFirst = index === 0; + const isLast = index === section.data.length - 1; + const radiusClass = isFirst && isLast + ? "rounded-xl" + : isFirst + ? "rounded-t-xl" + : isLast + ? "rounded-b-xl" + : ""; + + const memberAvatarUri = !isOwnRecord && item.member_avatar + ? buildFileUrl(item.member_avatar) || null + : null; return ( setActiveBill(item)} > - - + + + + + {memberAvatarUri && ( + + )} - - - {item.category_name || "未分类"} - - {!isOwnRecord && !!item.member_nickname && ( - - {item.member_nickname} - - )} - - - {remark || "无备注"} + + {item.category_name || "未分类"} + {!!remark && ( + + {remark} + + )} - {isIncome ? "+" : "-"}¥{formatMoney(Number(item.amount))} + {isIncome ? "+" : "-"}{formatMoney(Number(item.amount))} @@ -456,8 +484,7 @@ export default function Home() { - - + 筛选记账 - + - + setDraftFilter((prev) => ({ ...prev, keyword }))} @@ -494,22 +521,34 @@ export default function Home() { - - - setDraftFilter((prev) => ({ ...prev, categoryId: null }))} - > - - 全部 - - {categories.map(renderCategoryOption)} - + + + {/* 支出 */} + 支出 + + setDraftFilter((prev) => ({ ...prev, categoryId: null }))} + > + + 全部 + + {categories.filter(c => c.type === FinanceType.Expense).map(renderCategoryOption)} + + {/* 收入 */} + 收入 + + {categories.filter(c => c.type === FinanceType.Income).map(renderCategoryOption)} + + @@ -525,7 +564,6 @@ export default function Home() { 应用 - @@ -545,33 +583,51 @@ export default function Home() { }} /> - {showActionMenu && ( - { - if (!open) closeActionMenu(); - }} + {/* 长按操作菜单 */} + + - - - - - - 编辑 - - - - 删除 - - - - 详细 - - - - - )} + e.stopPropagation()} + > + {/* 编辑 */} + + + 编辑 + + {/* 删除 */} + + + 删除 + + {/* 详细 */} + + + 详细 + + + + diff --git a/code/app/app-rn/src/app/finance/add/index.tsx b/code/app/app-rn/src/app/finance/add/index.tsx index 6976c0f5..6ed97e0a 100644 --- a/code/app/app-rn/src/app/finance/add/index.tsx +++ b/code/app/app-rn/src/app/finance/add/index.tsx @@ -39,7 +39,7 @@ const CategoryItem = memo(function CategoryItem({ isActive ? "bg-accent" : "bg-gray-100" }`} > - + { - if (isSheetOpen) { - requestAnimationFrame(() => { - amountInputRef.current?.focus(); - }); - } else { + if (!isSheetOpen) { Keyboard.dismiss(); } }, [isSheetOpen]); @@ -299,59 +295,49 @@ export default function Add() { - {/* 璁拌处 BottomSheet */} - + {/* 记账 BottomSheet */} + amountInputRef.current?.focus()}> - - {/* 选中的分类 */} + {/* 第一行:分类 icon + 名称 | 日期 */} {selectedCategory && ( - - - + + + + + + + {selectedCategory.name} + - - {selectedCategory.name} - + setShowDatePicker(true)}> + + {date} + + + + )} - {/* 金额 */} - - + {/* 金额(居中、放大、无 label) */} + - - - {/* 日期 */} - - - setShowDatePicker(true)}> - - 选择日期 - - {date} - - - - - {/* 备注 */} @@ -375,7 +361,6 @@ export default function Add() { )} - diff --git a/code/app/app-rn/src/app/finance/category/index.tsx b/code/app/app-rn/src/app/finance/category/index.tsx index 8664f49e..fe690591 100644 --- a/code/app/app-rn/src/app/finance/category/index.tsx +++ b/code/app/app-rn/src/app/finance/category/index.tsx @@ -352,9 +352,9 @@ export default function FinanceCategoryList() { > - + {form && ( - + <> 保存 - + )} diff --git a/code/app/app-rn/src/components/BottomSheet.tsx b/code/app/app-rn/src/components/BottomSheet.tsx index 324c928a..0d42d893 100644 --- a/code/app/app-rn/src/components/BottomSheet.tsx +++ b/code/app/app-rn/src/components/BottomSheet.tsx @@ -1,10 +1,3 @@ -import { - BottomSheetBackdrop, - BottomSheetModal, - BottomSheetView, - useBottomSheetTimingConfigs, -} from "@gorhom/bottom-sheet"; -import type { BottomSheetModalProps } from "@gorhom/bottom-sheet"; import { Children, createContext, @@ -14,261 +7,257 @@ import { useContext, useEffect, useRef, + useState, } from "react"; import type { ComponentProps, ReactElement, ReactNode, Ref } from "react"; -import { Keyboard, Text, type TextInput, type TextProps } from "react-native"; -import { Easing } from "react-native-reanimated"; +import { + Animated, + Dimensions, + Keyboard, + Modal, + Pressable, + StyleSheet, + Text, + View, + type TextInput, + type TextProps, + type ViewStyle, +} from "react-native"; import { Input, useBottomSheetAwareHandlers } from "heroui-native"; -type AppBottomSheetProps = Omit< - BottomSheetModalProps, - "backdropComponent" | "animationConfigs" | "onDismiss" -> & { +// ── Props ────────────────────────────────────────────── + +type AppBottomSheetProps = { children: ReactNode; isOpen: boolean; onOpenChange?: (isOpen: boolean) => void; + onOpen?: () => void; backdropOpacity?: number; animationDuration?: number; + backgroundStyle?: ViewStyle; + handleIndicatorStyle?: ViewStyle; }; -type AppBottomSheetContentProps = ComponentProps & { - animateOnMount?: boolean; - animation?: "disabled" | "disable-all" | boolean; - backgroundClassName?: string; +type AppBottomSheetContentProps = { + children: ReactNode; + className?: string; contentContainerClassName?: string; - enableBlurKeyboardOnGesture?: boolean; - keyboardBlurBehavior?: AppBottomSheetProps["keyboardBlurBehavior"]; + animateOnMount?: boolean; + animation?: unknown; + backgroundClassName?: string; + style?: ViewStyle; }; -type AppBottomSheetContextValue = { - isOpen: boolean; - animationDuration: number; -}; +type CtxValue = { isOpen: boolean; animationDuration: number; registerInput: (node: TextInput | null) => void }; +type ContentCtxValue = CtxValue & { shouldAutoFocusSingleInput: boolean }; -type AppBottomSheetContentContextValue = AppBottomSheetContextValue & { - shouldAutoFocusSingleInput: boolean; -}; - -const AppBottomSheetContext = createContext({ - isOpen: false, - animationDuration: 300, +const AppBottomSheetContext = createContext({ isOpen: false, animationDuration: 300, registerInput: () => {} }); +const AppBottomSheetContentContext = createContext({ + isOpen: false, animationDuration: 300, registerInput: () => {}, shouldAutoFocusSingleInput: false, }); -const AppBottomSheetContentContext = createContext({ - isOpen: false, - animationDuration: 300, - shouldAutoFocusSingleInput: false, -}); +// ── Helpers ──────────────────────────────────────────── function setInputRef(ref: Ref, node: TextInput | null) { - if (typeof ref === "function") { - ref(node); - return; - } - - if (ref) { - ref.current = node; - } + if (typeof ref === "function") { ref(node); return; } + if (ref) { ref.current = node; } } - -function isBottomSheetInputElement(node: ReactNode) { +function isSheetInput(node: ReactNode) { if (!isValidElement(node)) return false; - - const type = node.type as { displayName?: string }; - - return ( - typeof type === "object" && - type !== null && - type.displayName === "AppBottomSheetInput" - ); + const t = node.type as { displayName?: string }; + return typeof t === "object" && t?.displayName === "AppBottomSheetInput"; } - -function countBottomSheetInputs(children: ReactNode): number { - let count = 0; - - Children.forEach(children, (child) => { +function countInputs(children: ReactNode): number { + let c = 0; + Children.forEach(children, child => { if (!isValidElement(child)) return; - - if (isBottomSheetInputElement(child)) { - count += 1; - return; - } - - const element = child as ReactElement<{ children?: ReactNode }>; - count += countBottomSheetInputs(element.props.children); + if (isSheetInput(child)) { c += 1; return; } + c += countInputs((child as ReactElement<{ children?: ReactNode }>).props.children); }); - - return count; + return c; } +// ── Root ─────────────────────────────────────────────── + +const SCREEN_H = Dimensions.get("window").height; + function AppBottomSheetRoot({ children, isOpen, onOpenChange, - backdropOpacity = 0.3, - animationDuration = 300, - enablePanDownToClose = true, - // interactive: 键盘弹出时 sheet 上移到键盘上方(gorhom 默认值) - // extend 只是拉到最高 snap point,不上移 → input 仍被键盘遮挡 - keyboardBehavior = "interactive", - keyboardBlurBehavior = "restore", - // adjustPan: 让 gorhom 自己处理键盘避让(gorhom 默认值) - // adjustResize 会在 Android 上导致 interactive 模式被跳过(见 BottomSheet.js 源码) - android_keyboardInputMode = "adjustPan", - enableBlurKeyboardOnGesture = true, + onOpen, + backdropOpacity = 0.35, + animationDuration = 260, backgroundStyle, handleIndicatorStyle, - ...props }: AppBottomSheetProps) { - const bottomSheetModalRef = useRef(null); - const wasPresentedRef = useRef(false); + const slideY = useRef(new Animated.Value(SCREEN_H)).current; + const backdropFade = useRef(new Animated.Value(0)).current; + const keyboardY = useRef(new Animated.Value(0)).current; + const panelY = useRef(Animated.add(slideY, keyboardY)).current; - const animationConfigs = useBottomSheetTimingConfigs({ - duration: animationDuration, - easing: Easing.out(Easing.cubic), - }); + const [visible, setVisible] = useState(false); + const animatingRef = useRef(false); + const focusedInputRef = useRef(null); + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; - const renderBackdrop = useCallback( - (backdropProps: ComponentProps) => ( - - ), - [backdropOpacity], - ); + const doAnimateIn = useCallback(() => { + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: animationDuration, useNativeDriver: true }), + Animated.timing(backdropFade, { toValue: 1, duration: animationDuration, useNativeDriver: true }), + ]).start(() => { + animatingRef.current = false; + onOpenRef.current?.(); + }); + }, [animationDuration]); + + useEffect(() => { + if (!visible) return; + const id = requestAnimationFrame(() => doAnimateIn()); + return () => cancelAnimationFrame(id); + }, [visible]); + + const doAnimateOut = useCallback((done: () => void) => { + Keyboard.dismiss(); + keyboardY.setValue(0); + Animated.parallel([ + Animated.timing(slideY, { toValue: SCREEN_H, duration: animationDuration, useNativeDriver: true }), + Animated.timing(backdropFade, { toValue: 0, duration: animationDuration, useNativeDriver: true }), + ]).start(() => { + animatingRef.current = false; + setVisible(false); + done(); + }); + }, [animationDuration]); useEffect(() => { if (isOpen) { - wasPresentedRef.current = true; - bottomSheetModalRef.current?.present(); - return; - } - - Keyboard.dismiss(); - - if (wasPresentedRef.current) { - bottomSheetModalRef.current?.dismiss(); + animatingRef.current = true; + slideY.setValue(SCREEN_H); + backdropFade.setValue(0); + setVisible(true); + } else if (visible) { + animatingRef.current = true; + doAnimateOut(() => onOpenChange?.(false)); } }, [isOpen]); - const handleDismiss = useCallback(() => { - wasPresentedRef.current = false; - onOpenChange?.(false); - }, [onOpenChange]); + const adjustForKeyboard = useCallback((kbHeight: number) => { + const input = focusedInputRef.current; + if (!input) { + keyboardY.setValue(-kbHeight); + return; + } + setTimeout(() => { + input.measureInWindow((x, y, w, h) => { + const screenBottom = SCREEN_H - kbHeight; + const inputBottom = y + h; + const needed = inputBottom - screenBottom - 16; + keyboardY.setValue(needed > 0 ? -needed : 0); + }); + }, 50); + }, []); + + const registerInput = useCallback((node: TextInput | null) => { + focusedInputRef.current = node; + }, []); + + useEffect(() => { + if (!visible) return; + const s1 = Keyboard.addListener("keyboardWillShow", (e) => adjustForKeyboard(e.endCoordinates.height)); + const s2 = Keyboard.addListener("keyboardDidShow", (e) => adjustForKeyboard(e.endCoordinates.height)); + const h1 = Keyboard.addListener("keyboardWillHide", () => keyboardY.setValue(0)); + const h2 = Keyboard.addListener("keyboardDidHide", () => keyboardY.setValue(0)); + return () => { s1.remove(); s2.remove(); h1.remove(); h2.remove(); }; + }, [visible, adjustForKeyboard]); + + if (!visible) return null; return ( - - + onOpenChange?.(false)} + statusBarTranslucent > - {children} - + onOpenChange?.(false)}> + + + + + + + + + {children} + + + ); } +// ── Portal / Overlay ─────────────────────────────────── + function AppBottomSheetPortal({ children }: { children: ReactNode }) { return <>{children}; } - function AppBottomSheetOverlay() { return null; } -function AppBottomSheetContent({ - children, - className, - contentContainerClassName, - animateOnMount: _animateOnMount, - animation: _animation, - backgroundClassName: _backgroundClassName, - enableBlurKeyboardOnGesture: _enableBlurKeyboardOnGesture, - keyboardBlurBehavior: _keyboardBlurBehavior, - style, - ...props -}: AppBottomSheetContentProps) { - const context = useContext(AppBottomSheetContext); - const inputCount = countBottomSheetInputs(children); +// ── Content ──────────────────────────────────────────── +function AppBottomSheetContent({ + children, className, contentContainerClassName, style, ...props +}: AppBottomSheetContentProps) { + const ctx = useContext(AppBottomSheetContext); + const inputCount = countInputs(children); return ( - - + )} > {children} - + ); } -function AppBottomSheetTitle(props: TextProps) { - return ; -} - -function AppBottomSheetDescription(props: TextProps) { - return ; -} +// ── Input ────────────────────────────────────────────── const AppBottomSheetInput = forwardRef>( ({ onFocus, onBlur, ...props }, ref) => { - // 用 heroui 原生 Input + useBottomSheetAwareHandlers 接入 sheet 键盘状态。 - // hook 内部调用 gorhom 的 useBottomSheetInternal(true)(unsafe 模式), - // 在 sheet context 外返回 no-op handlers,在 sheet 内则把 input 的 focus/blur - // 注册到 animatedKeyboardState,配合 keyboardBehavior="interactive" 触发上移避让。 - // 这样无需用 BottomSheetTextInput,保留 heroui Input 的全部样式与 API。 const handlers = useBottomSheetAwareHandlers(); const inputRef = useRef(null); - const { isOpen, animationDuration, shouldAutoFocusSingleInput } = useContext( - AppBottomSheetContentContext, - ); - const handleRef = useCallback( - (node: TextInput | null) => { - inputRef.current = node; - setInputRef(ref, node); - }, - [ref], - ); - - useEffect(() => { - if (!isOpen || !shouldAutoFocusSingleInput) return; - - const timer = setTimeout(() => { - inputRef.current?.focus(); - }, animationDuration); - - return () => clearTimeout(timer); - }, [animationDuration, isOpen, shouldAutoFocusSingleInput]); + const { registerInput } = useContext(AppBottomSheetContentContext); + const handleRef = useCallback((node: TextInput | null) => { inputRef.current = node; setInputRef(ref, node); }, [ref]); return ( { + onFocus={e => { handlers.onFocus(e); + registerInput(inputRef.current); onFocus?.(e); }} - onBlur={(e) => { + onBlur={e => { handlers.onBlur(e); onBlur?.(e); }} @@ -277,16 +266,18 @@ const AppBottomSheetInput = forwardRef>( ); }, ); - AppBottomSheetInput.displayName = "AppBottomSheetInput"; -const AppBottomSheet = Object.assign(AppBottomSheetRoot, { +function AppBottomSheetTitle(props: TextProps) { return ; } +function AppBottomSheetDescription(props: TextProps) { return ; } + +const BottomSheet = Object.assign(AppBottomSheetRoot, { Portal: AppBottomSheetPortal, Overlay: AppBottomSheetOverlay, Content: AppBottomSheetContent, + Input: AppBottomSheetInput, Title: AppBottomSheetTitle, Description: AppBottomSheetDescription, - Input: AppBottomSheetInput, }); -export default AppBottomSheet; +export default BottomSheet; diff --git a/code/app/app-rn/src/components/DateTime.tsx b/code/app/app-rn/src/components/DateTime.tsx index ce4a96b5..28c04308 100644 --- a/code/app/app-rn/src/components/DateTime.tsx +++ b/code/app/app-rn/src/components/DateTime.tsx @@ -63,8 +63,7 @@ export default function DateTime({ } }, [show, value]); - // 关闭:BottomSheet 的 onOpenChange(false) 或 confirm 后 setOpen(false) - // 立刻通知父组件,不延迟 —— BottomSheet 自身驱动关闭动画 + // 关闭 useEffect(() => { if (!open && show) { onCloseRef.current?.(); @@ -87,35 +86,36 @@ export default function DateTime({ }, [loading, tempDate, finalFormat, onConfirm]); return ( - - - - - - { - if (date) setTempDate(dayjs(date)); - }} - /> + + + + { + if (date) setTempDate(dayjs(date)); + }} + /> - - - - + + + ); } diff --git a/code/app/app-rn/src/components/DateTimeRange.tsx b/code/app/app-rn/src/components/DateTimeRange.tsx index 6683cfdd..78f29209 100644 --- a/code/app/app-rn/src/components/DateTimeRange.tsx +++ b/code/app/app-rn/src/components/DateTimeRange.tsx @@ -2,8 +2,8 @@ import DateTimePicker, { DateType } from "react-native-dates-picker"; import dayjs from "dayjs"; import { Button, Spinner, useThemeColor } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; -import { View } from "react-native"; import { useState, useEffect, useCallback, useRef } from "react"; +import { View } from "react-native"; import { CheckIcon } from "lucide-react-native"; import { FadeIn } from "react-native-reanimated"; @@ -69,37 +69,38 @@ export default function DateTimeRange({ }, [loading, range, onConfirm]); return ( - - - - - - setRange(params)} - /> + + + + setRange(params)} + /> - - - - + + + ); } diff --git a/code/app/app-rn/src/components/finance/FinanceCategoryIcon.tsx b/code/app/app-rn/src/components/finance/FinanceCategoryIcon.tsx index 0e46d375..473663e5 100644 --- a/code/app/app-rn/src/components/finance/FinanceCategoryIcon.tsx +++ b/code/app/app-rn/src/components/finance/FinanceCategoryIcon.tsx @@ -9,8 +9,6 @@ interface FinanceCategoryIconProps { size?: number; } -const DEFAULT_BG = "#F3F4F6"; - function FinanceCategoryIcon({ fileKey, label, size = 28 }: FinanceCategoryIconProps) { const [failed, setFailed] = useState(false); const uri = useMemo(() => buildFileUrl(fileKey), [fileKey]); @@ -26,7 +24,6 @@ function FinanceCategoryIcon({ fileKey, label, size = 28 }: FinanceCategoryIconP alignItems: "center", justifyContent: "center", overflow: "hidden", - backgroundColor: DEFAULT_BG, }} > {showImage ? ( diff --git a/code/app/app-rn/src/types/finance.ts b/code/app/app-rn/src/types/finance.ts index a5ed7d77..44e2b415 100644 --- a/code/app/app-rn/src/types/finance.ts +++ b/code/app/app-rn/src/types/finance.ts @@ -40,6 +40,7 @@ export interface FinanceBill { category_type: FinanceType; category_icon_file_key: string; member_nickname?: string; + member_avatar?: string; create_time?: string; update_time?: string; update_by?: number; diff --git a/code/app/app-rn/src/uniwind-types.d.ts b/code/app/app-rn/src/uniwind-types.d.ts index 14402dbc..cc099419 100644 --- a/code/app/app-rn/src/uniwind-types.d.ts +++ b/code/app/app-rn/src/uniwind-types.d.ts @@ -1,10 +1,10 @@ // NOTE: This file is generated by uniwind and it should not be edited manually. /// -declare module "uniwind" { - export interface UniwindConfig { - themes: readonly ["light", "dark"]; - } +declare module 'uniwind' { + export interface UniwindConfig { + themes: readonly ['light', 'dark'] + } } -export {}; +export {}