diff --git a/code/allapp/allapp-rn/src/app/(tabs)/money/components/CategorySheet.tsx b/code/allapp/allapp-rn/src/app/(tabs)/money/components/CategorySheet.tsx new file mode 100644 index 00000000..1c5c6234 --- /dev/null +++ b/code/allapp/allapp-rn/src/app/(tabs)/money/components/CategorySheet.tsx @@ -0,0 +1,193 @@ +import type { MoneyCategoryItem } from '@/types/money/category' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { + Actionsheet, + ActionsheetBackdrop, + ActionsheetContent, + ActionsheetDragIndicator, + ActionsheetDragIndicatorWrapper, + ActionsheetItem, + ActionsheetItemText, + ActionsheetSectionHeaderText, + ActionsheetSectionList, +} from '@/components/ui/actionsheet' + +interface CategorySection { + title: string + data: MoneyCategoryItem[] +} + +interface CategorySheetProps { + open?: boolean + moneyCategories?: MoneyCategoryItem[] + value?: number | null + onClose?: () => void + onSelect?: (category: MoneyCategoryItem) => void +} + +export default function CategorySheet({ + open = false, + moneyCategories = [], + value, + onClose, + onSelect, +}: CategorySheetProps) { + const listRef = useRef(null) + const hasAutoScrolled = useRef(false) + + // 记住目标滚动位置 + const pendingScrollRef = useRef<{ + sectionIndex: number + itemIndex: number + } | null>(null) + + const handleSelect = useCallback( + (item: MoneyCategoryItem) => { + onSelect?.(item) + onClose?.() + }, + [onSelect, onClose], + ) + + function createVirtualCategory( + id: number, + name: string, + type: number, + ): MoneyCategoryItem { + return { + id, + name, + type, + icon: '', + sort_number: -1, + } + } + + const sections = useMemo(() => { + const expense = moneyCategories.filter(i => i.type === 0) + const income = moneyCategories.filter(i => i.type === 1) + + return [ + { + title: '筛选', + data: [ + createVirtualCategory(-1, '全部', -1), + createVirtualCategory(-2, '支出', 0), + createVirtualCategory(-3, '收入', 1), + ], + }, + { + title: '支出分类', + data: expense, + }, + { + title: '收入分类', + data: income, + }, + ] + }, [moneyCategories]) + + useEffect(() => { + if (!open) { + hasAutoScrolled.current = false + pendingScrollRef.current = null + return + } + + if (hasAutoScrolled.current) + return + if (value == null) + return + + let sectionIndex = -1 + let itemIndex = -1 + + sections.some((section, sIndex) => { + const index = section.data.findIndex(i => i.id === value) + if (index !== -1) { + sectionIndex = sIndex + itemIndex = index + return true + } + return false + }) + + if (sectionIndex !== -1 && itemIndex !== -1) { + // 记录目标位置,供失败兜底使用 + pendingScrollRef.current = { sectionIndex, itemIndex } + requestAnimationFrame(() => { + listRef.current?.scrollToLocation({ + sectionIndex, + itemIndex, + animated: true, + viewPosition: 0.5, + }) + }) + + hasAutoScrolled.current = true + } + }, [open, value, sections]) + + return ( + + + + + + + + { + const target = pendingScrollRef.current + if (!target) + return + + setTimeout(() => { + listRef.current?.scrollToLocation({ + sectionIndex: target.sectionIndex, + itemIndex: target.itemIndex, + animated: true, + viewPosition: 0.5, + }) + }, 0) + }} + keyExtractor={(item, index) => + String((item as MoneyCategoryItem).id ?? index)} + renderItem={({ item }) => { + const category = item as MoneyCategoryItem + const selected = value === category.id + + return ( + handleSelect(category)} + > + + {category.name} + + + ) + }} + renderSectionHeader={({ section }) => { + const s = section as CategorySection + + return ( + + {s.title} + {' '} + ( + {s.data.length} + ) + + ) + }} + /> + + + ) +} diff --git a/code/allapp/allapp-rn/src/app/(tabs)/money/index.tsx b/code/allapp/allapp-rn/src/app/(tabs)/money/index.tsx index 22e6cf59..ee40ff79 100644 --- a/code/allapp/allapp-rn/src/app/(tabs)/money/index.tsx +++ b/code/allapp/allapp-rn/src/app/(tabs)/money/index.tsx @@ -6,13 +6,16 @@ import { ADatePicker, ADrawer, AListItem, LucideIcon } from '@/components/common import { Fab, FabIcon } from '@/components/ui/fab' import { AddIcon } from '@/components/ui/icon' import { deleteMoneyRecordApi, listMoneyRecordsApi } from '@/request/api/money/record' +import { useMoneyCategoryStore } from '@/store/moneyCategory' import { useMoneyRecordStore } from '@/store/moneyRecord' import { getCurrentYearMonth } from '@/utils/time' import { toast } from '@/utils/toast' +import CategorySheet from './components/CategorySheet' import TransactionsList from './components/TransactionsList' export default function Money() { const moneyRecordStore = useMoneyRecordStore() + const moneyCategoryStore = useMoneyCategoryStore() const router = useRouter() // 安全高度 const insets = useSafeAreaInsets() @@ -21,7 +24,9 @@ export default function Money() { const [showDatePicker, setShowDatePicker] = useState(false) const [searchParams, setSearchParams] = useState({ record_time: getCurrentYearMonth(), + category: -1, }) + const [showCategorySheet, setShowCategorySheet] = useState(false) // 操作菜单 const [showActionsMenu, setShowActionsMenu] = useState(false) @@ -68,13 +73,23 @@ export default function Money() { }} > - setShowDatePicker(true)} - className="flex flex-row items-center gap-1" - > - {searchParams.record_time} - - + + setShowDatePicker(true)} + className="flex flex-row items-center gap-1" + > + {searchParams.record_time} + + + + setShowCategorySheet(true)} + className="flex flex-row items-center gap-1" + > + 分类 + + + + { + setShowCategorySheet(false) + }} + onSelect={(category) => { + setSearchParams(prev => ({ + ...prev, + category: category.id, + })) + }} + /> + { diff --git a/code/allapp/allapp-rn/src/components/ui/actionsheet/index.tsx b/code/allapp/allapp-rn/src/components/ui/actionsheet/index.tsx new file mode 100644 index 00000000..4a2f6616 --- /dev/null +++ b/code/allapp/allapp-rn/src/components/ui/actionsheet/index.tsx @@ -0,0 +1,569 @@ +'use client'; +import React from 'react'; +import { H4 } from '@expo/html-elements'; +import { createActionsheet } from '@gluestack-ui/core/actionsheet/creator'; +import { + Pressable, + View, + Text, + ScrollView, + VirtualizedList, + FlatList, + SectionList, + PressableProps, + ViewStyle, +} from 'react-native'; +import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator'; +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; +import { cssInterop } from 'nativewind'; +import { + Motion, + AnimatePresence, + createMotionAnimatedComponent, + MotionComponentProps, +} from '@legendapp/motion'; + +const ItemWrapper = React.forwardRef< + React.ComponentRef, + PressableProps +>(function ItemWrapper({ ...props }, ref) { + return ; +}); + +type IMotionViewProps = React.ComponentProps & + MotionComponentProps; + +const MotionView = Motion.View as React.ComponentType; + +type IAnimatedPressableProps = React.ComponentProps & + MotionComponentProps; + +const AnimatedPressable = createMotionAnimatedComponent( + Pressable +) as React.ComponentType; + +export const UIActionsheet = createActionsheet({ + Root: View, + Content: MotionView, + Item: ItemWrapper, + ItemText: Text, + DragIndicator: View, + IndicatorWrapper: View, + Backdrop: AnimatedPressable, + ScrollView: ScrollView, + VirtualizedList: VirtualizedList, + FlatList: FlatList, + SectionList: SectionList, + SectionHeaderText: H4, + Icon: UIIcon, + AnimatePresence: AnimatePresence, +}); + +cssInterop(UIActionsheet, { className: 'style' }); +cssInterop(UIActionsheet.Content, { className: 'style' }); +cssInterop(ItemWrapper, { className: 'style' }); +cssInterop(UIActionsheet.ItemText, { className: 'style' }); +cssInterop(UIActionsheet.DragIndicator, { className: 'style' }); +cssInterop(UIActionsheet.DragIndicatorWrapper, { className: 'style' }); +cssInterop(UIActionsheet.Backdrop, { className: 'style' }); +cssInterop(UIActionsheet.ScrollView, { + className: 'style', + contentContainerClassName: 'contentContainerStyle', + indicatorClassName: 'indicatorStyle', +}); +cssInterop(UIActionsheet.VirtualizedList, { + className: 'style', + ListFooterComponentClassName: 'ListFooterComponentStyle', + ListHeaderComponentClassName: 'ListHeaderComponentStyle', + contentContainerClassName: 'contentContainerStyle', + indicatorClassName: 'indicatorStyle', +}); +cssInterop(UIActionsheet.FlatList, { + className: 'style', + ListFooterComponentClassName: 'ListFooterComponentStyle', + ListHeaderComponentClassName: 'ListHeaderComponentStyle', + columnWrapperClassName: 'columnWrapperStyle', + contentContainerClassName: 'contentContainerStyle', + indicatorClassName: 'indicatorStyle', +}); +cssInterop(UIActionsheet.SectionList, { className: 'style' }); +cssInterop(UIActionsheet.SectionHeaderText, { className: 'style' }); + +cssInterop(PrimitiveIcon, { + className: { + target: 'style', + nativeStyleToProp: { + height: true, + width: true, + fill: true, + color: 'classNameColor', + stroke: true, + }, + }, +}); + +const actionsheetStyle = tva({ base: 'w-full h-full web:pointer-events-none' }); + +const actionsheetContentStyle = tva({ + base: 'items-center rounded-tl-3xl rounded-tr-3xl p-5 pt-2 bg-background-0 web:pointer-events-auto web:select-none shadow-hard-5 border border-b-0 border-outline-100 pb-safe', +}); + +const actionsheetItemStyle = tva({ + base: 'w-full flex-row items-center p-3 rounded-sm data-[disabled=true]:opacity-40 data-[disabled=true]:web:pointer-events-auto data-[disabled=true]:web:cursor-not-allowed hover:bg-background-50 active:bg-background-100 data-[focus=true]:bg-background-100 web:data-[focus-visible=true]:bg-background-100 web:data-[focus-visible=true]:outline-indicator-primary gap-2', +}); + +const actionsheetItemTextStyle = tva({ + base: 'text-typography-700 font-normal font-body', + variants: { + isTruncated: { + true: '', + }, + bold: { + true: 'font-bold', + }, + underline: { + true: 'underline', + }, + strikeThrough: { + true: 'line-through', + }, + size: { + '2xs': 'text-2xs', + 'xs': 'text-xs', + 'sm': 'text-sm', + 'md': 'text-base', + 'lg': 'text-lg', + 'xl': 'text-xl', + '2xl': 'text-2xl', + '3xl': 'text-3xl', + '4xl': 'text-4xl', + '5xl': 'text-5xl', + '6xl': 'text-6xl', + }, + }, +}); + +const actionsheetDragIndicatorStyle = tva({ + base: 'w-16 h-1 bg-background-400 rounded-full', +}); + +const actionsheetDragIndicatorWrapperStyle = tva({ + base: 'w-full py-1 items-center', +}); + +const actionsheetBackdropStyle = tva({ + base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default web:pointer-events-auto', +}); + +const actionsheetScrollViewStyle = tva({ + base: 'w-full h-auto', +}); + +const actionsheetVirtualizedListStyle = tva({ + base: 'w-full h-auto', +}); + +const actionsheetFlatListStyle = tva({ + base: 'w-full h-auto', +}); + +const actionsheetSectionListStyle = tva({ + base: 'w-full h-auto', +}); + +const actionsheetSectionHeaderTextStyle = tva({ + base: 'leading-5 font-bold font-heading my-0 text-typography-500 p-3 uppercase', + variants: { + isTruncated: { + true: '', + }, + bold: { + true: 'font-bold', + }, + underline: { + true: 'underline', + }, + strikeThrough: { + true: 'line-through', + }, + size: { + '5xl': 'text-5xl', + '4xl': 'text-4xl', + '3xl': 'text-3xl', + '2xl': 'text-2xl', + 'xl': 'text-xl', + 'lg': 'text-lg', + 'md': 'text-base', + 'sm': 'text-sm', + 'xs': 'text-xs', + }, + + sub: { + true: 'text-xs', + }, + italic: { + true: 'italic', + }, + highlight: { + true: 'bg-yellow500', + }, + }, + defaultVariants: { + size: 'xs', + }, +}); + +const actionsheetIconStyle = tva({ + base: 'text-background-500 fill-none', + variants: { + size: { + '2xs': 'h-3 w-3', + 'xs': 'h-3.5 w-3.5', + 'sm': 'h-4 w-4', + 'md': 'w-[18px] h-[18px]', + 'lg': 'h-5 w-5', + 'xl': 'h-6 w-6', + }, + }, +}); + +type IActionsheetProps = VariantProps & + React.ComponentPropsWithoutRef; + +type IActionsheetContentProps = VariantProps & + React.ComponentPropsWithoutRef & { + className?: string; + }; + +type IActionsheetItemProps = VariantProps & + React.ComponentPropsWithoutRef; + +type IActionsheetItemTextProps = VariantProps & + React.ComponentPropsWithoutRef; + +type IActionsheetDragIndicatorProps = VariantProps< + typeof actionsheetDragIndicatorStyle +> & + React.ComponentPropsWithoutRef; + +type IActionsheetDragIndicatorWrapperProps = VariantProps< + typeof actionsheetDragIndicatorWrapperStyle +> & + React.ComponentPropsWithoutRef; + +type IActionsheetBackdropProps = VariantProps & + React.ComponentPropsWithoutRef & { + className?: string; + }; + +type IActionsheetScrollViewProps = VariantProps< + typeof actionsheetScrollViewStyle +> & + React.ComponentPropsWithoutRef; + +type IActionsheetVirtualizedListProps = VariantProps< + typeof actionsheetVirtualizedListStyle +> & + React.ComponentPropsWithoutRef; + +type IActionsheetFlatListProps = VariantProps & + React.ComponentPropsWithoutRef; + +type IActionsheetSectionListProps = VariantProps< + typeof actionsheetSectionListStyle +> & + React.ComponentPropsWithoutRef; + +type IActionsheetSectionHeaderTextProps = VariantProps< + typeof actionsheetSectionHeaderTextStyle +> & + React.ComponentPropsWithoutRef; + +type IActionsheetIconProps = VariantProps & + React.ComponentPropsWithoutRef & { + className?: string; + as?: React.ElementType; + height?: number; + width?: number; + }; + +const Actionsheet = React.forwardRef< + React.ComponentRef, + IActionsheetProps +>(function Actionsheet({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetContent = React.forwardRef< + React.ComponentRef, + IActionsheetContentProps +>(function ActionsheetContent({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetItem = React.forwardRef< + React.ComponentRef, + IActionsheetItemProps +>(function ActionsheetItem({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetItemText = React.forwardRef< + React.ComponentRef, + IActionsheetItemTextProps +>(function ActionsheetItemText( + { + isTruncated, + bold, + underline, + strikeThrough, + size = 'sm', + className, + ...props + }, + ref +) { + return ( + + ); +}); + +const ActionsheetDragIndicator = React.forwardRef< + React.ComponentRef, + IActionsheetDragIndicatorProps +>(function ActionsheetDragIndicator({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetDragIndicatorWrapper = React.forwardRef< + React.ComponentRef, + IActionsheetDragIndicatorWrapperProps +>(function ActionsheetDragIndicatorWrapper({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetBackdrop = React.forwardRef< + React.ComponentRef, + IActionsheetBackdropProps +>(function ActionsheetBackdrop({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetScrollView = React.forwardRef< + React.ComponentRef, + IActionsheetScrollViewProps +>(function ActionsheetScrollView({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetVirtualizedList = React.forwardRef< + React.ComponentRef, + IActionsheetVirtualizedListProps +>(function ActionsheetVirtualizedList({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetFlatList = React.forwardRef< + React.ComponentRef, + IActionsheetFlatListProps +>(function ActionsheetFlatList({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetSectionList = React.forwardRef< + React.ComponentRef, + IActionsheetSectionListProps +>(function ActionsheetSectionList({ className, ...props }, ref) { + return ( + + ); +}); + +const ActionsheetSectionHeaderText = React.forwardRef< + React.ComponentRef, + IActionsheetSectionHeaderTextProps +>(function ActionsheetSectionHeaderText( + { + className, + isTruncated, + bold, + underline, + strikeThrough, + size, + sub, + italic, + highlight, + ...props + }, + ref +) { + return ( + + ); +}); + +const ActionsheetIcon = React.forwardRef< + React.ComponentRef, + IActionsheetIconProps +>(function ActionsheetIcon({ className, size = 'sm', ...props }, ref) { + if (typeof size === 'number') { + return ( + + ); + } else if ( + (props.height !== undefined || props.width !== undefined) && + size === undefined + ) { + return ( + + ); + } + return ( + + ); +}); + +export { + Actionsheet, + ActionsheetContent, + ActionsheetItem, + ActionsheetItemText, + ActionsheetDragIndicator, + ActionsheetDragIndicatorWrapper, + ActionsheetBackdrop, + ActionsheetScrollView, + ActionsheetVirtualizedList, + ActionsheetFlatList, + ActionsheetSectionList, + ActionsheetSectionHeaderText, + ActionsheetIcon, +}; diff --git a/code/allapp/allapp-rn/src/request/index.ts b/code/allapp/allapp-rn/src/request/index.ts index 2f5e9787..1288fff7 100644 --- a/code/allapp/allapp-rn/src/request/index.ts +++ b/code/allapp/allapp-rn/src/request/index.ts @@ -28,8 +28,8 @@ function isWhiteListed(url: string) { } const alovaInstance = createAlova({ - // baseURL: 'http://192.168.5.6:8086/app', - baseURL: 'http://192.168.1.137:8086/app', + baseURL: 'http://192.168.5.6:8086/app', + // baseURL: 'http://192.168.1.137:8086/app', requestAdapter: axiosRequestAdapter(), timeout: 5000, cacheFor: null,