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