xref: /MusicFree/src/pages/fileSelector/index.tsx (revision b4c389f44ac4dad056e7314478fadd2eca82a4b1)
1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2import {Pressable, StyleSheet, View} from 'react-native';
3import rpx from '@/utils/rpx';
4import ThemeText from '@/components/base/themeText';
5import {
6    exists,
7    ExternalStorageDirectoryPath,
8    getAllExternalFilesDirs,
9    readDir,
10} from 'react-native-fs';
11import {FlatList} from 'react-native-gesture-handler';
12import useColors from '@/hooks/useColors';
13import IconButton from '@/components/base/iconButton';
14import FileItem from './fileItem';
15import Empty from '@/components/base/empty';
16import useHardwareBack from '@/hooks/useHardwareBack';
17import {useNavigation} from '@react-navigation/native';
18import Loading from '@/components/base/loading';
19import {useParams} from '@/core/router';
20import StatusBar from '@/components/base/statusBar';
21import VerticalSafeAreaView from '@/components/base/verticalSafeAreaView';
22import globalStyle from '@/constants/globalStyle';
23import Button from '@/components/base/textButton.tsx';
24
25interface IPathItem {
26    path: string;
27    parent: null | IPathItem;
28}
29
30interface IFileItem {
31    path: string;
32    type: 'file' | 'folder';
33}
34
35const ITEM_HEIGHT = rpx(96);
36
37export default function FileSelector() {
38    const {
39        fileType = 'file-and-folder',
40        multi = true,
41        actionText = '确定',
42        matchExtension,
43        onAction,
44    } = useParams<'file-selector'>() ?? {};
45
46    const [currentPath, setCurrentPath] = useState<IPathItem>({
47        path: '/',
48        parent: null,
49    });
50    const currentPathRef = useRef<IPathItem>(currentPath);
51    const [filesData, setFilesData] = useState<IFileItem[]>([]);
52    const [checkedItems, setCheckedItems] = useState<IFileItem[]>([]);
53
54    const checkedPaths = useMemo(
55        () => checkedItems.map(_ => _.path),
56        [checkedItems],
57    );
58    const navigation = useNavigation();
59    const colors = useColors();
60    const [loading, setLoading] = useState(false);
61
62    useEffect(() => {
63        (async () => {
64            // 路径变化时,重新读取
65            setLoading(true);
66            try {
67                if (currentPath.path === '/') {
68                    try {
69                        const allExt = await getAllExternalFilesDirs();
70                        if (allExt.length > 1) {
71                            const sdCardPaths = allExt.map(sdp =>
72                                sdp.substring(0, sdp.indexOf('/Android')),
73                            );
74                            if (
75                                (
76                                    await Promise.all(
77                                        sdCardPaths.map(_ => exists(_)),
78                                    )
79                                ).every(val => val)
80                            ) {
81                                setFilesData(
82                                    sdCardPaths.map(_ => ({
83                                        type: 'folder',
84                                        path: _,
85                                    })),
86                                );
87                            }
88                        } else {
89                            setCurrentPath({
90                                path: ExternalStorageDirectoryPath,
91                                parent: null,
92                            });
93                            return;
94                        }
95                    } catch {
96                        setCurrentPath({
97                            path: ExternalStorageDirectoryPath,
98                            parent: null,
99                        });
100                        return;
101                    }
102                } else {
103                    const res = (await readDir(currentPath.path)) ?? [];
104                    let folders: IFileItem[] = [];
105                    let files: IFileItem[] = [];
106                    if (
107                        fileType === 'folder' ||
108                        fileType === 'file-and-folder'
109                    ) {
110                        folders = res
111                            .filter(_ => _.isDirectory())
112                            .map(_ => ({
113                                type: 'folder',
114                                path: _.path,
115                            }));
116                    }
117                    if (fileType === 'file' || fileType === 'file-and-folder') {
118                        files = res
119                            .filter(
120                                _ =>
121                                    _.isFile() &&
122                                    (matchExtension
123                                        ? matchExtension(_.path)
124                                        : true),
125                            )
126                            .map(_ => ({
127                                type: 'file',
128                                path: _.path,
129                            }));
130                    }
131                    setFilesData([...folders, ...files]);
132                }
133            } catch {
134                setFilesData([]);
135            }
136            setLoading(false);
137            currentPathRef.current = currentPath;
138        })();
139    }, [currentPath.path]);
140
141    useHardwareBack(() => {
142        // 注意闭包
143        const _currentPath = currentPathRef.current;
144        if (_currentPath.parent !== null) {
145            setCurrentPath(_currentPath.parent);
146        } else {
147            navigation.goBack();
148        }
149        return true;
150    });
151
152    const selectPath = useCallback(
153        (item: IFileItem | IFileItem[], nextChecked: boolean) => {
154            if (multi) {
155                if (!Array.isArray(item)) {
156                    item = [item];
157                }
158                setCheckedItems(prev => {
159                    const itemPaths = (item as IFileItem[]).map(_ => _.path);
160                    const newCheckedItem = prev.filter(
161                        _ => !itemPaths.includes(_.path),
162                    );
163                    if (nextChecked) {
164                        return [...newCheckedItem, ...(item as IFileItem[])];
165                    } else {
166                        return newCheckedItem;
167                    }
168                });
169            } else {
170                setCheckedItems(
171                    nextChecked ? (Array.isArray(item) ? item : [item]) : [],
172                );
173            }
174        },
175        [],
176    );
177
178    const renderItem = ({item}: {item: IFileItem}) => (
179        <FileItem
180            path={item.path}
181            type={item.type}
182            parentPath={currentPath.path}
183            onItemPress={currentChecked => {
184                if (item.type === 'folder') {
185                    setCurrentPath(prev => ({
186                        parent: prev,
187                        path: item.path,
188                    }));
189                } else {
190                    selectPath(item, !currentChecked);
191                }
192            }}
193            checked={checkedPaths.includes(item.path)}
194            onCheckedChange={checked => {
195                selectPath(item, checked);
196            }}
197        />
198    );
199
200    const currentPageAllChecked = useMemo(() => {
201        return (
202            filesData.length &&
203            filesData.every(file => checkedPaths.includes(file.path))
204        );
205    }, [filesData, checkedPaths]);
206
207    const renderHeader = () => {
208        return multi ? (
209            <View style={style.selectAll}>
210                <Button
211                    onPress={() => {
212                        if (currentPageAllChecked) {
213                            selectPath(filesData, false);
214                        } else {
215                            selectPath(filesData, true);
216                        }
217                    }}>
218                    {currentPageAllChecked ? '全不选' : '全选'}
219                </Button>
220            </View>
221        ) : null;
222    };
223
224    return (
225        <VerticalSafeAreaView style={globalStyle.fwflex1}>
226            <StatusBar />
227            <View style={[style.header, {backgroundColor: colors.appBar}]}>
228                <IconButton
229                    sizeType="small"
230                    name="arrow-long-left"
231                    color={colors.appBarText}
232                    onPress={() => {
233                        // 返回上一级
234                        if (currentPath.parent !== null) {
235                            setCurrentPath(currentPath.parent);
236                        }
237                    }}
238                />
239                <ThemeText
240                    numberOfLines={2}
241                    ellipsizeMode="head"
242                    fontColor={'appBarText'}
243                    style={style.headerPath}>
244                    {currentPath.path}
245                </ThemeText>
246            </View>
247            {loading ? (
248                <Loading />
249            ) : (
250                <>
251                    <FlatList
252                        ListHeaderComponent={renderHeader}
253                        ListEmptyComponent={Empty}
254                        style={globalStyle.fwflex1}
255                        data={filesData}
256                        getItemLayout={(_, index) => ({
257                            length: ITEM_HEIGHT,
258                            offset: ITEM_HEIGHT * index,
259                            index,
260                        })}
261                        renderItem={renderItem}
262                    />
263                </>
264            )}
265            <Pressable
266                onPress={async () => {
267                    if (checkedItems.length) {
268                        const shouldBack = await onAction?.(checkedItems);
269                        if (shouldBack) {
270                            navigation.goBack();
271                        }
272                    }
273                }}>
274                <View
275                    style={[
276                        style.scanBtn,
277                        {
278                            backgroundColor: colors.appBar,
279                        },
280                    ]}>
281                    <ThemeText
282                        fontColor={'appBarText'}
283                        opacity={checkedItems.length > 0 ? undefined : 0.6}>
284                        {actionText}
285                        {multi && checkedItems?.length > 0
286                            ? ` (选中${checkedItems.length})`
287                            : ''}
288                    </ThemeText>
289                </View>
290            </Pressable>
291        </VerticalSafeAreaView>
292    );
293}
294
295const style = StyleSheet.create({
296    header: {
297        height: rpx(88),
298        flexDirection: 'row',
299        alignItems: 'center',
300        width: '100%',
301        paddingHorizontal: rpx(24),
302    },
303    headerPath: {
304        marginLeft: rpx(28),
305    },
306    scanBtn: {
307        width: '100%',
308        height: rpx(120),
309        alignItems: 'center',
310        justifyContent: 'center',
311    },
312    selectAll: {
313        width: '100%',
314        height: ITEM_HEIGHT,
315        paddingHorizontal: rpx(24),
316        flexDirection: 'row',
317        alignItems: 'center',
318    },
319});
320