from scratch
This commit is contained in:
@@ -1,148 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Modal } from 'react-native';
|
||||
|
||||
export interface ConfirmOption {
|
||||
label: string;
|
||||
onPress?: () => void;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
options: ConfirmOption[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmModal({ visible, title, message, options, onClose }: ConfirmModalProps) {
|
||||
const stacked = options.length > 2;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={onClose}>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
|
||||
<View style={styles.handle} />
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
<View style={[styles.optionsContainer, stacked && styles.optionsStacked]}>
|
||||
{options.map((opt, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[
|
||||
stacked ? styles.stackedBtn : styles.sideBtn,
|
||||
opt.destructive && (stacked ? styles.stackedDestructive : styles.sideDestructive),
|
||||
!opt.destructive && !stacked && styles.sideBtnSecondary,
|
||||
!opt.destructive && stacked && styles.stackedSecondary,
|
||||
i < options.length - 1 && stacked && styles.stackedBtnBorder,
|
||||
]}
|
||||
onPress={() => {
|
||||
onClose();
|
||||
opt.onPress?.();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
stacked ? styles.stackedBtnText : styles.sideBtnText,
|
||||
opt.destructive && (stacked ? styles.stackedDestructiveText : styles.sideDestructiveText),
|
||||
!opt.destructive && !stacked && styles.sideBtnSecondaryText,
|
||||
!opt.destructive && stacked && styles.stackedSecondaryText,
|
||||
]}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 20,
|
||||
width: '85%',
|
||||
},
|
||||
handle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ddd',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
optionsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
optionsStacked: {
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
},
|
||||
sideBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sideDestructive: {
|
||||
backgroundColor: '#E53935',
|
||||
},
|
||||
sideBtnSecondary: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
sideBtnText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sideDestructiveText: {
|
||||
color: '#fff',
|
||||
},
|
||||
sideBtnSecondaryText: {
|
||||
color: '#666',
|
||||
},
|
||||
stackedBtn: {
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
stackedBtnBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f0f0f0',
|
||||
},
|
||||
stackedDestructive: {},
|
||||
stackedSecondary: {},
|
||||
stackedBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
stackedDestructiveText: {
|
||||
color: '#E53935',
|
||||
},
|
||||
stackedSecondaryText: {
|
||||
color: '#333',
|
||||
},
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { FileItem } from '../types';
|
||||
import { TagChip } from './TagChip';
|
||||
import { SyncStatusBadge } from './SyncStatusBadge';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileItem;
|
||||
onPress?: (file: FileItem) => void;
|
||||
onLongPress?: (file: FileItem) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getFileInfo(mimeType: string, fileName: string): { icon: IconName; color: string; bg: string } {
|
||||
if (mimeType.startsWith('image/')) return { icon: 'image', color: '#4CAF50', bg: '#E8F5E9' };
|
||||
if (mimeType === 'application/pdf') return { icon: 'picture-as-pdf', color: '#E53935', bg: '#FFEBEE' };
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return { icon: 'description', color: '#1565C0', bg: '#E3F2FD' };
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv')) return { icon: 'table-chart', color: '#2E7D32', bg: '#E8F5E9' };
|
||||
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return { icon: 'slideshow', color: '#E65100', bg: '#FFF3E0' };
|
||||
if (mimeType.startsWith('text/')) return { icon: 'article', color: '#546E7A', bg: '#ECEFF1' };
|
||||
if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' };
|
||||
if (mimeType.startsWith('audio/')) return { icon: 'audiotrack', color: '#AD1457', bg: '#FCE4EC' };
|
||||
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('rar') || mimeType.includes('tar')) return { icon: 'folder-zip', color: '#6D4C41', bg: '#EFEBE9' };
|
||||
if (mimeType.includes('json') || mimeType.includes('xml')) return { icon: 'code', color: '#37474F', bg: '#ECEFF1' };
|
||||
return { icon: 'insert-drive-file', color: '#757575', bg: '#F5F5F5' };
|
||||
}
|
||||
|
||||
function getExtension(fileName: string): string {
|
||||
const ext = fileName.split('.').pop();
|
||||
return ext ? ext.toUpperCase() : '';
|
||||
}
|
||||
|
||||
export const FileCard = React.memo(function FileCard({ file, onPress, onLongPress, selected }: FileCardProps) {
|
||||
const info = getFileInfo(file.mimeType, file.name);
|
||||
const ext = getExtension(file.name);
|
||||
|
||||
const imageUri = file.thumbnailUrl || file.thumbnailLocal || (file.url && file.mimeType?.startsWith('image/') ? file.url : undefined) || (file.localUri && file.mimeType?.startsWith('image/') ? file.localUri : undefined);
|
||||
const isFolder = file.isFolder;
|
||||
const isUploading = file.isUploading;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, selected && styles.containerSelected]}
|
||||
onPress={() => onPress?.(file)}
|
||||
onLongPress={() => onLongPress?.(file)}
|
||||
delayLongPress={400}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.thumbnailWrap}>
|
||||
{isFolder ? (
|
||||
<View style={[styles.thumbnail, styles.placeholder, { backgroundColor: '#FFF3E0' }]}>
|
||||
<MaterialIcons name="folder" size={30} color="#F57C00" />
|
||||
</View>
|
||||
) : imageUri ? (
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.thumbnail}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.thumbnail, styles.placeholder, { backgroundColor: info.bg }]}>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator size="small" color="#1976D2" />
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name={info.icon} size={26} color={info.color} />
|
||||
{ext.length <= 4 && <Text style={[styles.ext, { color: info.color }]}>{ext}</Text>}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<View style={styles.selectedOverlay}>
|
||||
<View style={styles.checkCircle}>
|
||||
<MaterialIcons name="check" size={16} color="#fff" />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{file.syncStatus && !isUploading && (
|
||||
<View style={styles.badge}>
|
||||
<SyncStatusBadge status={file.syncStatus} size={16} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.body}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
{!isFolder && file.size > 0 && (
|
||||
<Text style={styles.size}>{formatSize(file.size)}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{file.syncStatus && !isUploading && (
|
||||
<View style={styles.statusRow}>
|
||||
<SyncStatusBadge status={file.syncStatus} showLabel />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isUploading ? (
|
||||
<View style={styles.uploadRow}>
|
||||
<MaterialIcons name="cloud-upload" size={14} color="#1976D2" />
|
||||
<Text style={styles.uploadText}>
|
||||
Upload {(file.uploadProgress ?? 0)}%
|
||||
</Text>
|
||||
</View>
|
||||
) : file.ocrText ? (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
{file.ocrText}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{file.tags && file.tags.length > 0 && (
|
||||
<View style={styles.tags}>
|
||||
{file.tags.slice(0, 3).map((tag) => (
|
||||
<TagChip key={tag.id} name={tag.tag_name} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
marginBottom: 8,
|
||||
gap: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 1,
|
||||
},
|
||||
containerSelected: {
|
||||
opacity: 0.85,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#1976D2',
|
||||
},
|
||||
thumbnailWrap: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnail: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 8,
|
||||
},
|
||||
placeholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
},
|
||||
ext: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
},
|
||||
selectedOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-end',
|
||||
padding: 4,
|
||||
},
|
||||
checkCircle: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: '#1976D2',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
gap: 8,
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
},
|
||||
size: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 6,
|
||||
},
|
||||
preview: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginBottom: 6,
|
||||
lineHeight: 18,
|
||||
},
|
||||
uploadRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 6,
|
||||
},
|
||||
uploadText: {
|
||||
fontSize: 13,
|
||||
color: '#1976D2',
|
||||
fontWeight: '500',
|
||||
},
|
||||
tags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
},
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { SyncStatusBadge } from './SyncStatusBadge';
|
||||
import type { SyncStatus } from '../types';
|
||||
|
||||
type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||
|
||||
interface FileTypeInfo {
|
||||
icon: IconName;
|
||||
color: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
function getFileInfo(mimeType: string, fileName: string): FileTypeInfo {
|
||||
if (mimeType.startsWith('image/')) return { icon: 'image', color: '#4CAF50', bg: '#E8F5E9' };
|
||||
if (mimeType === 'application/pdf') return { icon: 'picture-as-pdf', color: '#E53935', bg: '#FFEBEE' };
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return { icon: 'description', color: '#1565C0', bg: '#E3F2FD' };
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv')) return { icon: 'table-chart', color: '#2E7D32', bg: '#E8F5E9' };
|
||||
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return { icon: 'slideshow', color: '#E65100', bg: '#FFF3E0' };
|
||||
if (mimeType.startsWith('text/')) return { icon: 'article', color: '#546E7A', bg: '#ECEFF1' };
|
||||
if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' };
|
||||
if (mimeType.startsWith('audio/')) return { icon: 'audiotrack', color: '#AD1457', bg: '#FCE4EC' };
|
||||
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('rar') || mimeType.includes('tar')) return { icon: 'folder-zip', color: '#6D4C41', bg: '#EFEBE9' };
|
||||
if (mimeType.includes('json') || mimeType.includes('xml')) return { icon: 'code', color: '#37474F', bg: '#ECEFF1' };
|
||||
return { icon: 'insert-drive-file', color: '#757575', bg: '#F5F5F5' };
|
||||
}
|
||||
|
||||
function getExtension(fileName: string): string {
|
||||
const ext = fileName.split('.').pop();
|
||||
return ext ? ext.toUpperCase() : '';
|
||||
}
|
||||
|
||||
interface FileThumbnailProps {
|
||||
uri?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailLocal?: string;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
isLoading?: boolean;
|
||||
syncStatus?: SyncStatus;
|
||||
isFolder?: boolean;
|
||||
isUploading?: boolean;
|
||||
uploadProgress?: number;
|
||||
}
|
||||
|
||||
export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, thumbnailLocal, mimeType, fileName, size, isLoading, syncStatus, isFolder, isUploading, uploadProgress }: FileThumbnailProps) {
|
||||
const info = getFileInfo(mimeType, fileName);
|
||||
const ext = getExtension(fileName);
|
||||
|
||||
if (isFolder) {
|
||||
return (
|
||||
<View style={[styles.container, { width: size, height: size, backgroundColor: '#FFF3E0' }]}>
|
||||
<MaterialIcons name="folder" size={size * 0.45} color="#F57C00" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { width: size, height: size, backgroundColor: info.bg }]}>
|
||||
<ActivityIndicator size="small" color={info.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUri = thumbnailUrl || thumbnailLocal || (uri && mimeType.startsWith('image/') ? uri : undefined);
|
||||
|
||||
if (imageUri) {
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<Image
|
||||
source={imageUri}
|
||||
style={[styles.image, { width: size, height: size }]}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||
{isUploading && (
|
||||
<View style={styles.uploadOverlay}>
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
<Text style={styles.uploadProgressText}>{uploadProgress ?? 0}%</Text>
|
||||
<View style={[styles.uploadProgressBar, { width: `${uploadProgress ?? 0}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: size, height: size, backgroundColor: isUploading ? '#E3F2FD' : info.bg }]}>
|
||||
{isUploading ? (
|
||||
<View style={styles.uploadGhost}>
|
||||
<MaterialIcons name="cloud-upload" size={size * 0.3} color="#1976D2" />
|
||||
<ActivityIndicator size="small" color="#1976D2" />
|
||||
<Text style={styles.uploadPercent}>{uploadProgress ?? 0}%</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name={info.icon} size={size * 0.35} color={info.color} />
|
||||
{ext.length <= 4 && (
|
||||
<Text style={[styles.ext, { color: info.color }]}>{ext}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 6,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
image: {
|
||||
borderRadius: 6,
|
||||
},
|
||||
ext: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
uploadGhost: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
uploadPercent: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: '#1976D2',
|
||||
},
|
||||
uploadOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
borderRadius: 6,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
uploadProgressText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
uploadProgressBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
height: 3,
|
||||
backgroundColor: '#1976D2',
|
||||
borderBottomLeftRadius: 6,
|
||||
},
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
|
||||
export function NetworkStatusBar() {
|
||||
const { isOnline } = useNetworkStatus();
|
||||
|
||||
if (isOnline) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.dotOnline} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.offlineBadge}>
|
||||
<MaterialIcons name="cloud-off" size={14} color="#fff" />
|
||||
<Text style={styles.offlineText}>Offline</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginRight: 4,
|
||||
padding: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dotOnline: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
offlineBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#E53935',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
gap: 4,
|
||||
},
|
||||
offlineText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Image, FlatList, StyleSheet, TouchableOpacity, Text } from 'react-native';
|
||||
import { CapturedPhoto } from '../types';
|
||||
|
||||
interface PhotoThumbnailStripProps {
|
||||
photos: CapturedPhoto[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PhotoThumbnailStrip({ photos, onRemove }: PhotoThumbnailStripProps) {
|
||||
if (photos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={photos}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.list}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.thumb}>
|
||||
<Image source={{ uri: item.uri }} style={styles.image} />
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={() => onRemove(item.id)}
|
||||
>
|
||||
<Text style={styles.removeText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
bottom: 120,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 80,
|
||||
},
|
||||
list: {
|
||||
paddingHorizontal: 16,
|
||||
gap: 8,
|
||||
},
|
||||
thumb: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
removeButton: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeText: {
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||
|
||||
export interface SearchFilters {
|
||||
name: boolean;
|
||||
ocrText: boolean;
|
||||
}
|
||||
|
||||
export type SortKey = 'date' | 'name' | 'size';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortState {
|
||||
key: SortKey;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
interface SearchBarProps {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
onClear: () => void;
|
||||
filters: SearchFilters;
|
||||
onFiltersChange: (f: SearchFilters) => void;
|
||||
onSettingsPress: () => void;
|
||||
sort: SortState;
|
||||
bottomPadding?: number;
|
||||
}
|
||||
|
||||
export function SearchBar({ query, onQueryChange, onClear, filters, onSettingsPress, sort, bottomPadding = 0 }: SearchBarProps) {
|
||||
const hasActiveFilter = filters.name || filters.ocrText || sort.key !== 'date';
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, { paddingBottom: bottomPadding }]}>
|
||||
<View style={styles.inputRow}>
|
||||
<View style={styles.inputContainer}>
|
||||
<MaterialIcons name="search" size={20} color="#999" style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Rechercher..."
|
||||
placeholderTextColor="#999"
|
||||
value={query}
|
||||
onChangeText={onQueryChange}
|
||||
returnKeyType="search"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<TouchableOpacity onPress={onClear} style={styles.clearBtn}>
|
||||
<MaterialIcons name="close" size={18} color="#999" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.iconBtn, hasActiveFilter && styles.iconBtnActive]}
|
||||
onPress={onSettingsPress}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="tune"
|
||||
size={22}
|
||||
color={hasActiveFilter ? '#fff' : '#1976D2'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
gap: 8,
|
||||
},
|
||||
inputContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
clearBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
iconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1976D2',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
iconBtnActive: {
|
||||
backgroundColor: '#1976D2',
|
||||
},
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS } from 'react-native-reanimated';
|
||||
|
||||
const PANEL_HEIGHT = 300;
|
||||
const PANEL_HEADER_VISIBLE = 80;
|
||||
|
||||
interface SelectionPanelProps {
|
||||
selectedCount: number;
|
||||
onClose: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onTags?: () => void;
|
||||
onFolder?: () => void;
|
||||
onMove?: () => void;
|
||||
insetsBottom: number;
|
||||
}
|
||||
|
||||
export function SelectionPanel({
|
||||
selectedCount,
|
||||
onClose,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onTags,
|
||||
onFolder,
|
||||
onMove,
|
||||
insetsBottom,
|
||||
}: SelectionPanelProps) {
|
||||
const panelOffset = useSharedValue(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
|
||||
const panelStartY = useSharedValue(0);
|
||||
const isPanelExpanded = useSharedValue(false);
|
||||
const [panelExpanded, setPanelExpanded] = React.useState(false);
|
||||
|
||||
const togglePanelJS = useCallback(() => {
|
||||
if (isPanelExpanded.value) {
|
||||
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
|
||||
isPanelExpanded.value = false;
|
||||
setPanelExpanded(false);
|
||||
} else {
|
||||
panelOffset.value = withSpring(0);
|
||||
isPanelExpanded.value = true;
|
||||
setPanelExpanded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
panelStartY.value = panelOffset.value;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const offset = Math.max(0, Math.min(PANEL_HEIGHT - PANEL_HEADER_VISIBLE, panelStartY.value + event.translationY));
|
||||
panelOffset.value = offset;
|
||||
})
|
||||
.onEnd(() => {
|
||||
if (panelOffset.value > (PANEL_HEIGHT - PANEL_HEADER_VISIBLE) / 2) {
|
||||
panelOffset.value = withSpring(PANEL_HEIGHT - PANEL_HEADER_VISIBLE);
|
||||
isPanelExpanded.value = false;
|
||||
runOnJS(setPanelExpanded)(false);
|
||||
} else {
|
||||
panelOffset.value = withSpring(0);
|
||||
isPanelExpanded.value = true;
|
||||
runOnJS(setPanelExpanded)(true);
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: panelOffset.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View style={[styles.panel, { paddingBottom: insetsBottom + 12 }, panelAnimatedStyle]}>
|
||||
<TouchableOpacity onPress={togglePanelJS} activeOpacity={0.7}>
|
||||
<View style={styles.panelHandle} />
|
||||
<View style={styles.panelHeader}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<MaterialIcons name="close" size={22} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.selectionCount}>
|
||||
{selectedCount} sélectionnée{selectedCount > 1 ? 's' : ''}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.selectAllBtn}>
|
||||
<Text style={styles.selectAllText}>Tout</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.panelBody}>
|
||||
<View style={styles.actionsGrid}>
|
||||
{onDelete && (
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.deleteBtn]} onPress={onDelete}>
|
||||
<MaterialIcons name="delete" size={22} color="#E53935" />
|
||||
<Text style={[styles.actionLabel, { color: '#E53935' }]}>Supprimer</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onEdit && (
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.editBtn]} onPress={onEdit}>
|
||||
<MaterialIcons name="edit" size={22} color="#1E88E5" />
|
||||
<Text style={[styles.actionLabel, { color: '#1E88E5' }]}>Éditer</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onTags && (
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.tagBtn]} onPress={onTags}>
|
||||
<MaterialIcons name="label" size={22} color="#8E24AA" />
|
||||
<Text style={[styles.actionLabel, { color: '#8E24AA' }]}>Tags</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onFolder && (
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.folderBtn]} onPress={onFolder}>
|
||||
<MaterialIcons name="create-new-folder" size={22} color="#F57C00" />
|
||||
<Text style={[styles.actionLabel, { color: '#F57C00' }]}>Dossier</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onMove && (
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.moveBtn]} onPress={onMove}>
|
||||
<MaterialIcons name="drive-file-move" size={22} color="#00897B" />
|
||||
<Text style={[styles.actionLabel, { color: '#00897B' }]}>Déplacer</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
panel: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
height: PANEL_HEIGHT,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
},
|
||||
panelHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ddd',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
panelHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
closeBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
selectionCount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
},
|
||||
selectAllBtn: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
selectAllText: {
|
||||
fontSize: 14,
|
||||
color: '#1976D2',
|
||||
fontWeight: '600',
|
||||
},
|
||||
panelBody: {
|
||||
flex: 1,
|
||||
},
|
||||
actionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
actionBtn: {
|
||||
width: '47%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
actionLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteBtn: {},
|
||||
editBtn: {},
|
||||
tagBtn: {},
|
||||
folderBtn: {},
|
||||
moveBtn: {},
|
||||
});
|
||||
@@ -1,670 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { StoredFolder, SyncMode, SyncGlobalMode, type FolderSource } from '../services/safDirectory';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
type SettingsView = 'menu' | 'folders' | 'sync';
|
||||
|
||||
interface SettingsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
folders: StoredFolder[];
|
||||
onToggleVisibility: (folderId: string) => void;
|
||||
onRemoveFolder: (folderId: string) => void;
|
||||
onAddFolderRecursive: () => void;
|
||||
onUpdateSyncMode: (folderId: string, mode: SyncMode) => void;
|
||||
onUpdateSyncCellular: (folderId: string, enabled: boolean) => void;
|
||||
globalSyncMode: SyncGlobalMode;
|
||||
onSetGlobalSyncMode: (mode: SyncGlobalMode) => void;
|
||||
globalSyncCellular: boolean;
|
||||
onSetGlobalSyncCellular: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const SYNC_MODES: { value: SyncMode; label: string; icon: string; color: string }[] = [
|
||||
{ value: 'none', label: 'Aucun', icon: 'sync-disabled', color: '#999' },
|
||||
{ value: 'manual', label: 'Manuel', icon: 'sync', color: '#1976D2' },
|
||||
{ value: 'auto', label: 'Auto', icon: 'sync-problem', color: '#43A047' },
|
||||
];
|
||||
|
||||
const GLOBAL_MODES: { value: SyncGlobalMode; label: string; description: string; icon: string; color: string }[] = [
|
||||
{ value: 'off', label: 'Désactivé', description: 'Aucun upload automatique', icon: 'sync-disabled', color: '#999' },
|
||||
{ value: 'auto', label: 'Automatique', description: 'Tout synchroniser automatiquement', icon: 'sync-problem', color: '#43A047' },
|
||||
{ value: 'manual', label: 'Par dossier', description: 'Choisir dossier par dossier', icon: 'tune', color: '#1976D2' },
|
||||
];
|
||||
|
||||
export function SettingsModal({
|
||||
visible,
|
||||
onClose,
|
||||
folders,
|
||||
onToggleVisibility,
|
||||
onRemoveFolder,
|
||||
onAddFolderRecursive,
|
||||
onUpdateSyncMode,
|
||||
onUpdateSyncCellular,
|
||||
globalSyncMode,
|
||||
onSetGlobalSyncMode,
|
||||
globalSyncCellular,
|
||||
onSetGlobalSyncCellular,
|
||||
}: SettingsModalProps) {
|
||||
const [view, setView] = useState<SettingsView>('menu');
|
||||
const [confirmRemoveFolder, setConfirmRemoveFolder] = useState<StoredFolder | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setView('menu');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleRemoveFolder = useCallback(
|
||||
(folder: StoredFolder) => {
|
||||
setConfirmRemoveFolder(folder);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRemoveFolderConfirm = useCallback(() => {
|
||||
if (confirmRemoveFolder) {
|
||||
onRemoveFolder(confirmRemoveFolder.id);
|
||||
}
|
||||
setConfirmRemoveFolder(null);
|
||||
}, [confirmRemoveFolder, onRemoveFolder]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={handleClose}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
|
||||
<View style={styles.handle} />
|
||||
|
||||
{view === 'menu' && (
|
||||
<MenuView
|
||||
onSelect={(v) => setView(v)}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'folders' && (
|
||||
<FoldersView
|
||||
folders={folders}
|
||||
onBack={() => setView('menu')}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
onRemoveFolder={handleRemoveFolder}
|
||||
onAddFolderRecursive={onAddFolderRecursive}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'sync' && (
|
||||
<SyncView
|
||||
folders={folders}
|
||||
onBack={() => setView('menu')}
|
||||
onUpdateSyncMode={onUpdateSyncMode}
|
||||
onUpdateSyncCellular={onUpdateSyncCellular}
|
||||
globalSyncMode={globalSyncMode}
|
||||
onSetGlobalSyncMode={onSetGlobalSyncMode}
|
||||
globalSyncCellular={globalSyncCellular}
|
||||
onSetGlobalSyncCellular={onSetGlobalSyncCellular}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
|
||||
<ConfirmModal
|
||||
visible={confirmRemoveFolder !== null}
|
||||
title="Supprimer le dossier"
|
||||
message="Le dossier sera retiré de la liste. Les fichiers resteront sur votre appareil."
|
||||
options={[
|
||||
{ label: 'Annuler' },
|
||||
{ label: 'Supprimer', destructive: true, onPress: handleRemoveFolderConfirm },
|
||||
]}
|
||||
onClose={() => setConfirmRemoveFolder(null)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuView({ onSelect, onClose }: { onSelect: (v: SettingsView) => void; onClose: () => void }) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.title}>Paramètres</Text>
|
||||
|
||||
<TouchableOpacity style={styles.menuItem} onPress={() => onSelect('folders')}>
|
||||
<View style={[styles.menuIcon, { backgroundColor: '#FFF3E0' }]}>
|
||||
<MaterialIcons name="folder" size={22} color="#F57C00" />
|
||||
</View>
|
||||
<View style={styles.menuTextContainer}>
|
||||
<Text style={styles.menuLabel}>Dossiers</Text>
|
||||
<Text style={styles.menuDescription}>Gérer les dossiers affichés</Text>
|
||||
</View>
|
||||
<MaterialIcons name="chevron-right" size={24} color="#ccc" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.menuItem} onPress={() => onSelect('sync')}>
|
||||
<View style={[styles.menuIcon, { backgroundColor: '#E3F2FD' }]}>
|
||||
<MaterialIcons name="cloud-sync" size={22} color="#1976D2" />
|
||||
</View>
|
||||
<View style={styles.menuTextContainer}>
|
||||
<Text style={styles.menuLabel}>Synchronisation</Text>
|
||||
<Text style={styles.menuDescription}>Configurer l'upload automatique</Text>
|
||||
</View>
|
||||
<MaterialIcons name="chevron-right" size={24} color="#ccc" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||
<Text style={styles.closeBtnText}>Fermer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function folderIcon(folder: StoredFolder): { name: keyof typeof MaterialIcons.glyphMap; color: string } {
|
||||
switch (folder.source) {
|
||||
case 'media-library':
|
||||
return { name: 'photo-library', color: '#43A047' };
|
||||
case 'recursive':
|
||||
return { name: 'subdirectory-arrow-right', color: '#8E24AA' };
|
||||
default:
|
||||
return { name: 'folder', color: '#F57C00' };
|
||||
}
|
||||
}
|
||||
|
||||
function FoldersView({
|
||||
folders,
|
||||
onBack,
|
||||
onToggleVisibility,
|
||||
onRemoveFolder,
|
||||
onAddFolderRecursive,
|
||||
}: {
|
||||
folders: StoredFolder[];
|
||||
onBack: () => void;
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onRemoveFolder: (folder: StoredFolder) => void;
|
||||
onAddFolderRecursive: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.viewHeader}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
|
||||
<MaterialIcons name="arrow-back" size={22} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Dossiers</Text>
|
||||
</View>
|
||||
|
||||
{folders.length === 0 ? (
|
||||
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
|
||||
) : (
|
||||
folders.map((folder) => {
|
||||
const icon = folderIcon(folder);
|
||||
return (
|
||||
<View key={folder.id} style={styles.folderRow}>
|
||||
<MaterialIcons name={icon.name} size={20} color={icon.color} />
|
||||
<Text style={styles.folderName} numberOfLines={1}>
|
||||
{folder.name}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onToggleVisibility(folder.id)}
|
||||
style={styles.actionBtn}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={folder.visible ? 'visibility' : 'visibility-off'}
|
||||
size={20}
|
||||
color={folder.visible ? '#1976D2' : '#999'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemoveFolder(folder)}
|
||||
style={styles.actionBtn}
|
||||
>
|
||||
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.addBtn} onPress={onAddFolderRecursive}>
|
||||
<MaterialIcons name="add" size={20} color="#fff" />
|
||||
<Text style={styles.addBtnText}>Ajouter un dossier</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncView({
|
||||
folders,
|
||||
onBack,
|
||||
onUpdateSyncMode,
|
||||
onUpdateSyncCellular,
|
||||
globalSyncMode,
|
||||
onSetGlobalSyncMode,
|
||||
globalSyncCellular,
|
||||
onSetGlobalSyncCellular,
|
||||
}: {
|
||||
folders: StoredFolder[];
|
||||
onBack: () => void;
|
||||
onUpdateSyncMode: (id: string, mode: SyncMode) => void;
|
||||
onUpdateSyncCellular: (id: string, enabled: boolean) => void;
|
||||
globalSyncMode: SyncGlobalMode;
|
||||
onSetGlobalSyncMode: (mode: SyncGlobalMode) => void;
|
||||
globalSyncCellular: boolean;
|
||||
onSetGlobalSyncCellular: (enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.viewHeader}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
|
||||
<MaterialIcons name="arrow-back" size={22} color="#333" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Synchronisation</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.syncInfo}>
|
||||
Choisissez comment vos fichiers sont envoyés au serveur.
|
||||
</Text>
|
||||
|
||||
<View style={styles.globalModeCard}>
|
||||
<Text style={styles.sectionLabel}>Mode de synchronisation</Text>
|
||||
{GLOBAL_MODES.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.globalModeRow,
|
||||
globalSyncMode === mode.value && styles.globalModeRowActive,
|
||||
]}
|
||||
onPress={() => onSetGlobalSyncMode(mode.value)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={globalSyncMode === mode.value ? 'radio-button-checked' : 'radio-button-unchecked'}
|
||||
size={20}
|
||||
color={globalSyncMode === mode.value ? mode.color : '#999'}
|
||||
/>
|
||||
<View style={styles.globalModeTextContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.globalModeLabel,
|
||||
globalSyncMode === mode.value && { color: mode.color },
|
||||
]}
|
||||
>
|
||||
{mode.label}
|
||||
</Text>
|
||||
<Text style={styles.globalModeDescription}>{mode.description}</Text>
|
||||
</View>
|
||||
<MaterialIcons
|
||||
name={mode.icon as any}
|
||||
size={20}
|
||||
color={globalSyncMode === mode.value ? mode.color : '#ccc'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{globalSyncMode === 'auto' && (
|
||||
<View style={styles.cellularCard}>
|
||||
<View style={styles.cellularRow}>
|
||||
<MaterialIcons name="cell-tower" size={20} color="#666" />
|
||||
<Text style={styles.cellularLabel}>Autoriser le réseau cellulaire</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.toggleBtn,
|
||||
globalSyncCellular && styles.toggleBtnActive,
|
||||
]}
|
||||
onPress={() => onSetGlobalSyncCellular(!globalSyncCellular)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.toggleDot,
|
||||
globalSyncCellular && styles.toggleDotActive,
|
||||
]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.cellularHint}>
|
||||
{globalSyncCellular
|
||||
? 'Upload via WiFi et données mobiles'
|
||||
: 'Upload uniquement en WiFi'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{globalSyncMode === 'manual' && (
|
||||
<View style={styles.perFolderSection}>
|
||||
<Text style={styles.sectionLabel}>Configuration par dossier</Text>
|
||||
{folders.length === 0 ? (
|
||||
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
|
||||
) : (
|
||||
folders.map((folder) => (
|
||||
<View key={folder.id} style={styles.syncFolderCard}>
|
||||
<View style={styles.syncFolderHeader}>
|
||||
<MaterialIcons name="folder" size={18} color="#F57C00" />
|
||||
<Text style={styles.syncFolderName} numberOfLines={1}>
|
||||
{folder.name}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.radioGroup}>
|
||||
{SYNC_MODES.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.radioBtn,
|
||||
folder.syncMode === mode.value && styles.radioBtnActive,
|
||||
]}
|
||||
onPress={() => onUpdateSyncMode(folder.id, mode.value)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={
|
||||
folder.syncMode === mode.value
|
||||
? 'radio-button-checked'
|
||||
: 'radio-button-unchecked'
|
||||
}
|
||||
size={18}
|
||||
color={folder.syncMode === mode.value ? mode.color : '#999'}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.radioLabel,
|
||||
folder.syncMode === mode.value && { color: mode.color },
|
||||
]}
|
||||
>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{folder.syncMode !== 'none' && (
|
||||
<View style={styles.cellularRow}>
|
||||
<MaterialIcons name="cell-tower" size={18} color="#666" />
|
||||
<Text style={styles.cellularLabel}>Réseau cellulaire</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.toggleBtn,
|
||||
folder.syncCellular && styles.toggleBtnActive,
|
||||
]}
|
||||
onPress={() => onUpdateSyncCellular(folder.id, !folder.syncCellular)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.toggleDot,
|
||||
folder.syncCellular && styles.toggleDotActive,
|
||||
]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 20,
|
||||
width: '85%',
|
||||
maxHeight: '80%',
|
||||
},
|
||||
handle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ddd',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 16,
|
||||
},
|
||||
menuIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
gap: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f0f0f0',
|
||||
},
|
||||
menuTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
menuLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
menuDescription: {
|
||||
fontSize: 13,
|
||||
color: '#999',
|
||||
marginTop: 2,
|
||||
},
|
||||
closeBtn: {
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
closeBtnText: {
|
||||
fontSize: 15,
|
||||
color: '#666',
|
||||
},
|
||||
viewContainer: {
|
||||
maxHeight: 500,
|
||||
},
|
||||
viewHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
backBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginVertical: 20,
|
||||
},
|
||||
folderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
gap: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f0f0f0',
|
||||
},
|
||||
folderName: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#333',
|
||||
},
|
||||
actionBtn: {
|
||||
padding: 6,
|
||||
},
|
||||
addBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F57C00',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
marginTop: 16,
|
||||
gap: 8,
|
||||
},
|
||||
addBtnText: {
|
||||
fontSize: 15,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
syncInfo: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
lineHeight: 18,
|
||||
},
|
||||
globalModeCard: {
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
globalModeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 8,
|
||||
gap: 10,
|
||||
borderRadius: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
globalModeRowActive: {
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
globalModeTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
globalModeLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
globalModeDescription: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 2,
|
||||
},
|
||||
cellularCard: {
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cellularRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
cellularLabel: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
cellularHint: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 6,
|
||||
marginLeft: 28,
|
||||
},
|
||||
perFolderSection: {
|
||||
marginTop: 4,
|
||||
},
|
||||
syncFolderCard: {
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
},
|
||||
syncFolderHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
},
|
||||
syncFolderName: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
},
|
||||
radioGroup: {
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
marginBottom: 10,
|
||||
},
|
||||
radioBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 6,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
gap: 4,
|
||||
},
|
||||
radioBtnActive: {
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
radioLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#999',
|
||||
},
|
||||
toggleBtn: {
|
||||
width: 44,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#ddd',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
toggleBtnActive: {
|
||||
backgroundColor: '#1976D2',
|
||||
},
|
||||
toggleDot: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
toggleDotActive: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { SortState } from './SearchBar';
|
||||
|
||||
type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||
|
||||
interface SortChipsProps {
|
||||
sort: SortState;
|
||||
onSortChange: (s: SortState) => void;
|
||||
}
|
||||
|
||||
const OPTIONS: { key: SortState['key']; label: string; icon: IconName }[] = [
|
||||
{ key: 'name', label: 'A-Z', icon: 'sort-by-alpha' },
|
||||
{ key: 'date', label: 'Date', icon: 'schedule' },
|
||||
];
|
||||
|
||||
export function SortChips({ sort, onSortChange }: SortChipsProps) {
|
||||
const select = (key: SortState['key']) => {
|
||||
if (sort.key === key) {
|
||||
onSortChange({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' });
|
||||
} else {
|
||||
onSortChange({ key, direction: key === 'name' ? 'asc' : 'desc' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{OPTIONS.map((opt) => {
|
||||
const active = sort.key === opt.key;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={opt.key}
|
||||
style={[styles.chip, active && styles.chipActive]}
|
||||
onPress={() => select(opt.key)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={active && sort.direction === 'desc' ? 'arrow-downward' : active && sort.direction === 'asc' ? 'arrow-upward' : opt.icon}
|
||||
size={16}
|
||||
color={active ? '#fff' : '#1976D2'}
|
||||
/>
|
||||
<Text style={[styles.chipText, active && styles.chipTextActive]}>{opt.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1976D2',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: '#1976D2',
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#1976D2',
|
||||
},
|
||||
chipTextActive: {
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { SyncStatus } from '../types';
|
||||
|
||||
interface SyncStatusBadgeProps {
|
||||
status: SyncStatus;
|
||||
size?: number;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<SyncStatus, { icon: string; color: string; bg: string; label: string }> = {
|
||||
local: { icon: 'phone-android', color: '#757575', bg: '#F5F5F5', label: 'Local' },
|
||||
syncing: { icon: 'sync', color: '#FF9800', bg: '#FFF3E0', label: 'Sync...' },
|
||||
synced: { icon: 'sync', color: '#4CAF50', bg: '#E8F5E9', label: 'Les deux' },
|
||||
cloud: { icon: 'cloud', color: '#1976D2', bg: '#E3F2FD', label: 'Cloud' },
|
||||
conflict: { icon: 'warning', color: '#E53935', bg: '#FFEBEE', label: 'Conflit' },
|
||||
};
|
||||
|
||||
export function SyncStatusBadge({ status, size = 16, showLabel }: SyncStatusBadgeProps) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const iconSize = Math.round(size * 0.7);
|
||||
|
||||
if (showLabel) {
|
||||
return (
|
||||
<View style={[styles.pill, { backgroundColor: config.bg }]}>
|
||||
<MaterialIcons name={config.icon as any} size={12} color={config.color} />
|
||||
<Text style={[styles.pillText, { color: config.color }]}>{config.label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.badge, { width: size, height: size, borderRadius: size / 2, backgroundColor: config.bg }]}>
|
||||
<MaterialIcons name={config.icon as any} size={iconSize} color={config.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
pill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
},
|
||||
pillText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface SyncStatusIconProps {
|
||||
isSyncing: boolean;
|
||||
pendingCount: number;
|
||||
isUploading: boolean;
|
||||
uploadPendingCount: number;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function SyncStatusIcon({ isSyncing, pendingCount, isUploading, uploadPendingCount, onPress }: SyncStatusIconProps) {
|
||||
const rotation = useSharedValue(0);
|
||||
const isActive = isSyncing || isUploading;
|
||||
const totalPending = pendingCount + uploadPendingCount;
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
rotation.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(360, { duration: 1000, easing: Easing.linear }),
|
||||
withTiming(0, { duration: 0 })
|
||||
),
|
||||
-1
|
||||
);
|
||||
} else {
|
||||
cancelAnimation(rotation);
|
||||
rotation.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}, [isActive, rotation]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.container}>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<MaterialIcons
|
||||
name="sync"
|
||||
size={22}
|
||||
color={isActive ? '#1976D2' : totalPending > 0 ? '#F57C00' : '#666'}
|
||||
/>
|
||||
</Animated.View>
|
||||
{totalPending > 0 && !isActive && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>
|
||||
{totalPending > 99 ? '99+' : totalPending}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
marginRight: 4,
|
||||
padding: 8,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 0,
|
||||
backgroundColor: '#E53935',
|
||||
borderRadius: 8,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
interface TagChipProps {
|
||||
name: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function TagChip({ name, onRemove }: TagChipProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>{name}</Text>
|
||||
{onRemove && (
|
||||
<TouchableOpacity onPress={onRemove} style={styles.removeButton}>
|
||||
<Text style={styles.removeText}>×</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#E3F2FD',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
gap: 4,
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
color: '#1976D2',
|
||||
fontWeight: '500',
|
||||
},
|
||||
removeButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#BBDEFB',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeText: {
|
||||
fontSize: 12,
|
||||
color: '#1976D2',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { UploadFile } from '../services/uploadQueue';
|
||||
import { useUploadQueue } from '../hooks/useUploadQueue';
|
||||
|
||||
interface UploadModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UploadModal({ visible, onClose }: UploadModalProps) {
|
||||
const { enqueue } = useUploadQueue();
|
||||
|
||||
const pickDocuments = async () => {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
multiple: true,
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (result.canceled || result.assets.length === 0) return;
|
||||
|
||||
const files = result.assets.map((asset) => ({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'application/octet-stream',
|
||||
name: asset.name,
|
||||
}));
|
||||
|
||||
enqueue(files);
|
||||
onClose();
|
||||
} catch {
|
||||
Alert.alert('Erreur', "Impossible de sélectionner des documents");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
|
||||
<View style={styles.handle} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Ajouter des fichiers</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||
<MaterialIcons name="close" size={22} color="#999" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.docButton}
|
||||
onPress={pickDocuments}
|
||||
>
|
||||
<MaterialIcons name="description" size={20} color="#fff" />
|
||||
<Text style={styles.buttonText}>Sélectionner des documents</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 20,
|
||||
width: '85%',
|
||||
},
|
||||
handle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ddd',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
},
|
||||
closeBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
docButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#4CAF50',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
gap: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
|
||||
interface UploadProgressProps {
|
||||
progress?: number;
|
||||
status: 'idle' | 'uploading' | 'processing' | 'success' | 'error';
|
||||
error?: string;
|
||||
uploadedCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export function UploadProgress({ progress, status, error, uploadedCount, totalCount }: UploadProgressProps) {
|
||||
if (status === 'idle') return null;
|
||||
|
||||
const hasMulti = totalCount !== undefined && totalCount > 1;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{status === 'uploading' && (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#1976D2" />
|
||||
<Text style={styles.text}>
|
||||
{hasMulti
|
||||
? `Upload ${uploadedCount || 0}/${totalCount}...`
|
||||
: `Upload en cours...${progress !== undefined ? ` ${progress}%` : ''}`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'processing' && (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#1976D2" />
|
||||
<Text style={styles.text}>Traitement OCR en cours...</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Text style={[styles.text, styles.success]}>
|
||||
{hasMulti ? `${uploadedCount} fichiers uploadés !` : 'Upload terminé !'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Text style={[styles.text, styles.error]}>
|
||||
{error || "Erreur lors de l'upload"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
text: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
success: {
|
||||
color: '#4CAF50',
|
||||
},
|
||||
error: {
|
||||
color: '#F44336',
|
||||
},
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { StyleSheet, Pressable, View } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated';
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from 'react-native-gesture-handler';
|
||||
|
||||
interface ZoomableImageProps {
|
||||
uri: string;
|
||||
width: number;
|
||||
height: number;
|
||||
onClose?: () => void;
|
||||
onSwipeVertical?: (direction: 'up' | 'down') => void;
|
||||
}
|
||||
|
||||
const MAX_SCALE = 4;
|
||||
const DOUBLE_TAP_SCALE = 2.5;
|
||||
const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.5 };
|
||||
|
||||
export function ZoomableImage({ uri, width, height, onClose, onSwipeVertical }: ZoomableImageProps) {
|
||||
const scale = useSharedValue(1);
|
||||
const savedScale = useSharedValue(1);
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const savedTranslateX = useSharedValue(0);
|
||||
const savedTranslateY = useSharedValue(0);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
|
||||
onSwipeVertical?.(direction);
|
||||
}, [onSwipeVertical]);
|
||||
|
||||
const pinch = Gesture.Pinch()
|
||||
.onUpdate((e) => {
|
||||
scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), MAX_SCALE);
|
||||
})
|
||||
.onEnd(() => {
|
||||
if (scale.value < 1) {
|
||||
scale.value = withSpring(1, SPRING_CONFIG);
|
||||
translateX.value = withSpring(0, SPRING_CONFIG);
|
||||
translateY.value = withSpring(0, SPRING_CONFIG);
|
||||
savedScale.value = 1;
|
||||
savedTranslateX.value = 0;
|
||||
savedTranslateY.value = 0;
|
||||
} else {
|
||||
savedScale.value = scale.value;
|
||||
}
|
||||
});
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.minDistance(5)
|
||||
.onUpdate((e) => {
|
||||
if (savedScale.value > 1) {
|
||||
translateX.value = savedTranslateX.value + e.translationX;
|
||||
translateY.value = savedTranslateY.value + e.translationY;
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (savedScale.value <= 1) {
|
||||
const absY = Math.abs(e.translationY);
|
||||
const absX = Math.abs(e.translationX);
|
||||
if (absY > 30 && absY > absX) {
|
||||
runOnJS(handleSwipeVertical)(e.translationY > 0 ? 'down' : 'up');
|
||||
}
|
||||
translateX.value = withSpring(0, SPRING_CONFIG);
|
||||
translateY.value = withSpring(0, SPRING_CONFIG);
|
||||
}
|
||||
savedTranslateX.value = translateX.value;
|
||||
savedTranslateY.value = translateY.value;
|
||||
});
|
||||
|
||||
const zoomGestures = Gesture.Simultaneous(pinch, pan);
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.maxDuration(250)
|
||||
.onEnd(() => {
|
||||
if (scale.value > 1) {
|
||||
scale.value = withTiming(1, { duration: 200 });
|
||||
translateX.value = withTiming(0, { duration: 200 });
|
||||
translateY.value = withTiming(0, { duration: 200 });
|
||||
savedScale.value = 1;
|
||||
savedTranslateX.value = 0;
|
||||
savedTranslateY.value = 0;
|
||||
} else {
|
||||
scale.value = withTiming(DOUBLE_TAP_SCALE, { duration: 200 });
|
||||
savedScale.value = DOUBLE_TAP_SCALE;
|
||||
}
|
||||
});
|
||||
|
||||
const singleTap = Gesture.Tap()
|
||||
.maxDuration(250)
|
||||
.onEnd(() => {
|
||||
if (scale.value <= 1) {
|
||||
runOnJS(handleClose)();
|
||||
}
|
||||
});
|
||||
|
||||
const taps = Gesture.Exclusive(doubleTap, singleTap);
|
||||
|
||||
const composed = Gesture.Race(zoomGestures, taps);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ scale: scale.value },
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<GestureDetector gesture={composed}>
|
||||
<Animated.Image
|
||||
source={{ uri }}
|
||||
style={[styles.image, { width, height }, animatedStyle]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</GestureDetector>
|
||||
{onClose && (
|
||||
<Pressable style={styles.closeButton} onPress={onClose}>
|
||||
<View style={styles.closeIcon}>
|
||||
<View style={[styles.closeLine, styles.closeLine1]} />
|
||||
<View style={[styles.closeLine, styles.closeLine2]} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CLOSE_SIZE = 36;
|
||||
const CLOSE_LINE = 20;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
image: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 56,
|
||||
right: 16,
|
||||
width: CLOSE_SIZE,
|
||||
height: CLOSE_SIZE,
|
||||
borderRadius: CLOSE_SIZE / 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeIcon: {
|
||||
width: CLOSE_LINE,
|
||||
height: CLOSE_LINE,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeLine: {
|
||||
position: 'absolute',
|
||||
width: CLOSE_LINE,
|
||||
height: 2,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 1,
|
||||
},
|
||||
closeLine1: {
|
||||
transform: [{ rotate: '45deg' }],
|
||||
},
|
||||
closeLine2: {
|
||||
transform: [{ rotate: '-45deg' }],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user