feat(Materials): add new materials navigation and href functions
- Introduced href functions for accessing materials, including hrefMaterials, hrefMaterialSubject, and hrefMaterialDetail. - Updated AppsScreen to include a new entry for materials, enhancing the app's educational resources section. - Exported material types in index.ts to support the new materials functionality.
This commit is contained in:
404
src/screens/material/SubjectMaterialsScreen.tsx
Normal file
404
src/screens/material/SubjectMaterialsScreen.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 学科资料列表页:展示某个学科下的所有资料
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StatusBar,
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import {
|
||||
spacing,
|
||||
fontSizes,
|
||||
borderRadius,
|
||||
shadows,
|
||||
useAppColors,
|
||||
useResolvedColorScheme,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { Text, ResponsiveContainer, Loading } from '../../components/common';
|
||||
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import type { MaterialSubject, MaterialFile, MaterialFileType } from '../../types/material';
|
||||
import {
|
||||
materialRepository,
|
||||
} from '../../data/repositories/MaterialRepository';
|
||||
import {
|
||||
MATERIAL_FILE_TYPE_ICONS,
|
||||
MATERIAL_FILE_TYPE_COLORS,
|
||||
formatFileSize,
|
||||
} from '../../types/material';
|
||||
|
||||
export const SubjectMaterialsScreen: React.FC = () => {
|
||||
const colors = useAppColors();
|
||||
const resolvedScheme = useResolvedColorScheme();
|
||||
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
|
||||
const styles = useMemo(() => createSubjectMaterialsStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ subjectId: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isMobile } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||
|
||||
const [subject, setSubject] = useState<MaterialSubject | null>(null);
|
||||
const [materials, setMaterials] = useState<MaterialFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedFileType, setSelectedFileType] = useState<MaterialFileType | null>(null);
|
||||
|
||||
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!params.subjectId) {
|
||||
setError('缺少学科ID');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const [subjectData, materialsData] = await Promise.all([
|
||||
materialRepository.getSubjectById(params.subjectId),
|
||||
materialRepository.getMaterials({ subjectId: params.subjectId }),
|
||||
]);
|
||||
setSubject(subjectData);
|
||||
setMaterials(materialsData.materials);
|
||||
} catch (err) {
|
||||
setError('加载资料列表失败');
|
||||
console.error('Failed to load materials:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [params.subjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsRefreshing(true);
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const onOpenMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
router.push(hrefs.hrefMaterialDetail(materialId));
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const filteredMaterials = useMemo(() => {
|
||||
if (!selectedFileType) return materials;
|
||||
return materials.filter((m) => m.file_type === selectedFileType);
|
||||
}, [materials, selectedFileType]);
|
||||
|
||||
const fileTypes: { type: MaterialFileType | null; label: string }[] = [
|
||||
{ type: null, label: '全部' },
|
||||
{ type: 'pdf', label: 'PDF' },
|
||||
{ type: 'word', label: 'Word' },
|
||||
{ type: 'ppt', label: 'PPT' },
|
||||
];
|
||||
|
||||
const cardStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden' as const,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 1,
|
||||
}),
|
||||
[colors]
|
||||
);
|
||||
|
||||
const renderMaterialItem = ({ item }: { item: MaterialFile }) => {
|
||||
const fileColor = MATERIAL_FILE_TYPE_COLORS[item.file_type];
|
||||
const iconName = MATERIAL_FILE_TYPE_ICONS[item.file_type];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.materialCard, cardStyle]}
|
||||
onPress={() => onOpenMaterial(item.id)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={[styles.fileTypeIcon, { backgroundColor: fileColor }]}>
|
||||
<MaterialCommunityIcons name={iconName as any} size={24} color="#FFFFFF" />
|
||||
</View>
|
||||
<View style={styles.materialContent}>
|
||||
<Text variant="body" style={styles.materialTitle} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View style={styles.materialMeta}>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
{formatFileSize(item.file_size)}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
{' · '}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
{item.download_count} 次下载
|
||||
</Text>
|
||||
</View>
|
||||
{item.description && (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFilterTabs = () => (
|
||||
<View style={styles.filterContainer}>
|
||||
{fileTypes.map(({ type, label }) => (
|
||||
<TouchableOpacity
|
||||
key={label}
|
||||
style={[
|
||||
styles.filterTab,
|
||||
selectedFileType === type && styles.filterTabActive,
|
||||
selectedFileType === type && { backgroundColor: colors.primary.main },
|
||||
]}
|
||||
onPress={() => setSelectedFileType(type)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={selectedFileType === type ? colors.primary.contrast : colors.text.secondary}
|
||||
style={styles.filterTabText}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderEmptyComponent = () => (
|
||||
<View style={styles.centerContainer}>
|
||||
<MaterialCommunityIcons name="file-document-outline" size={64} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.emptyText}>
|
||||
{selectedFileType ? '该类型暂无资料' : '暂无资料'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<Loading size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<MaterialCommunityIcons name="alert-circle-outline" size={48} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.errorText}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={loadData} style={styles.retryButton}>
|
||||
<Text variant="body" color={colors.primary.main}>
|
||||
重试
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={filteredMaterials}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderMaterialItem}
|
||||
ListHeaderComponent={renderFilterTabs}
|
||||
ListEmptyComponent={renderEmptyComponent}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.default} />
|
||||
|
||||
{/* Header */}
|
||||
<View style={[styles.header, isWideScreen && styles.headerWide]}>
|
||||
<View style={styles.headerLeft}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={[styles.headerTitle, isWideScreen && styles.headerTitleWide]}>
|
||||
{subject?.name || '资料列表'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity style={styles.searchButton}>
|
||||
<MaterialCommunityIcons name="magnify" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset }]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubjectMaterialsScreen;
|
||||
|
||||
function createSubjectMaterialsStyles(colors: AppColors) {
|
||||
const headerBg = colors.background.default;
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: headerBg,
|
||||
...shadows.sm,
|
||||
},
|
||||
headerWide: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
headerLeft: {
|
||||
width: 44,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
backButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
headerCenter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
headerTitleWide: {
|
||||
fontSize: 22,
|
||||
},
|
||||
headerRight: {
|
||||
width: 44,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
searchButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
filterTab: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
filterTabActive: {
|
||||
// backgroundColor set inline
|
||||
},
|
||||
filterTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
materialCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
fileTypeIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: borderRadius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
materialContent: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
materialTitle: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
materialMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing['3xl'],
|
||||
},
|
||||
errorText: {
|
||||
marginTop: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user