423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import * as DocumentPicker from 'expo-document-picker';
|
|
import { api, DocCategory, DocRequirement, LocalFile, Property } from '@/api';
|
|
import { useT } from '@/i18n';
|
|
import { Button, Field, IconButton, Label, TextField } from '@/components/ui';
|
|
import { DateField } from '@/components/DateField';
|
|
import { FileGlyph } from '@/components/domain';
|
|
import { pickImageFromLibrary } from '@/lib/pickImage';
|
|
import { isCameraBlocked, openPermissionSettings, primeDocumentPermissions } from '@/lib/permissions';
|
|
import { scanDocumentToPdf } from '@/scanner/scanToPdf';
|
|
import { formatBytes } from '@/lib/format';
|
|
import { categoryColor, colors, fontSize, radii, spacing } from '@/theme';
|
|
|
|
export default function UploadScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const router = useRouter();
|
|
const qc = useQueryClient();
|
|
const t = useT();
|
|
const params = useLocalSearchParams<{ propertyId?: string }>();
|
|
|
|
const propertiesQuery = useQuery({ queryKey: ['properties'], queryFn: () => api.properties.list() });
|
|
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
|
|
const properties: Property[] = propertiesQuery.data ?? [];
|
|
const categories: DocCategory[] = categoriesQuery.data ?? [];
|
|
|
|
const [file, setFile] = useState<LocalFile | null>(null);
|
|
const [name, setName] = useState('');
|
|
const [propertyId, setPropertyId] = useState<number | null>(params.propertyId ? Number(params.propertyId) : null);
|
|
const [category, setCategory] = useState<string>('');
|
|
const [requirementId, setRequirementId] = useState<number | null>(null);
|
|
const [pickingRequirement, setPickingRequirement] = useState(false);
|
|
const [expiry, setExpiry] = useState<string | null>(null);
|
|
const [pickingProperty, setPickingProperty] = useState(false);
|
|
const [scanning, setScanning] = useState(false);
|
|
|
|
const effectivePropertyId = propertyId ?? properties[0]?.id ?? null;
|
|
const effectiveCategory = category || categories[0]?.slug || '';
|
|
|
|
// Files hang off a requirement, so they load per category and are picked.
|
|
const requirementsQuery = useQuery({
|
|
queryKey: ['requirements', effectiveCategory],
|
|
queryFn: () => api.reference.requirements(effectiveCategory),
|
|
enabled: !!effectiveCategory,
|
|
});
|
|
const requirements: DocRequirement[] = requirementsQuery.data ?? [];
|
|
const selectedRequirement = requirements.find((r) => r.id === requirementId) ?? null;
|
|
const selectedProperty = properties.find((p) => p.id === effectivePropertyId);
|
|
|
|
const uploadMutation = useMutation({
|
|
mutationFn: () =>
|
|
api.documents.upload({
|
|
propertyId: effectivePropertyId as number,
|
|
name: name.trim(),
|
|
category: effectiveCategory,
|
|
requirementId: requirementId ?? undefined,
|
|
expiryDate: expiry,
|
|
file: file as LocalFile,
|
|
}),
|
|
onSuccess: () => {
|
|
// Fire-and-forget: the lists update as the refetches land, and the modal
|
|
// should not wait on them.
|
|
qc.invalidateQueries({ queryKey: ['documents'] });
|
|
qc.invalidateQueries({ queryKey: ['all-documents'] });
|
|
qc.invalidateQueries({ queryKey: ['properties'] });
|
|
router.back();
|
|
},
|
|
onError: (e: any) => Alert.alert(t('upload.failed'), e?.message ?? t('common.tryAgain')),
|
|
});
|
|
|
|
function applyFile(f: LocalFile, suggestedName?: string) {
|
|
setFile(f);
|
|
if (!name && suggestedName) setName(suggestedName);
|
|
}
|
|
|
|
async function onChooseFile() {
|
|
const res = await DocumentPicker.getDocumentAsync({
|
|
type: ['application/pdf', 'image/*'],
|
|
copyToCacheDirectory: true,
|
|
});
|
|
if (res.canceled || !res.assets?.length) return;
|
|
const a = res.assets[0];
|
|
applyFile(
|
|
{ uri: a.uri, name: a.name, mimeType: a.mimeType ?? 'application/octet-stream', sizeBytes: a.size ?? 0 },
|
|
a.name.replace(/\.[^.]+$/, ''),
|
|
);
|
|
}
|
|
|
|
async function onPickPhoto() {
|
|
const picked = await pickImageFromLibrary({ quality: 0.9, fallbackName: 'photo' });
|
|
if (picked) applyFile(picked, t('upload.photoName'));
|
|
}
|
|
|
|
async function onScan() {
|
|
setScanning(true);
|
|
try {
|
|
const result = await scanDocumentToPdf(name || t('upload.scannedName'));
|
|
if (result) {
|
|
applyFile(result.file, t('upload.scannedName'));
|
|
}
|
|
} catch (e: any) {
|
|
// A blocked camera can't be fixed by retrying.
|
|
if (await isCameraBlocked()) openPermissionSettings('permissions.cameraBody');
|
|
else Alert.alert(t('upload.scannerUnavailable'), e?.message ?? t('upload.scannerUnavailableBody'));
|
|
} finally {
|
|
setScanning(false);
|
|
}
|
|
}
|
|
|
|
// Asked on open, so nobody is stopped halfway through picking a source.
|
|
useEffect(() => {
|
|
void primeDocumentPermissions();
|
|
}, []);
|
|
|
|
function onSubmit() {
|
|
if (!file) return Alert.alert(t('upload.noFileTitle'), t('upload.noFileBody'));
|
|
if (!name.trim()) return Alert.alert(t('upload.nameRequired'), t('upload.nameRequiredBody'));
|
|
if (!effectivePropertyId) return Alert.alert(t('upload.noPropertyTitle'), t('upload.noPropertyBody'));
|
|
// Guessing the requirement files the document in the wrong place.
|
|
if (requirements.length > 0 && !requirementId) {
|
|
return Alert.alert(t('upload.noRequirementTitle'), t('upload.noRequirementBody'));
|
|
}
|
|
uploadMutation.mutate();
|
|
}
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.surface }}>
|
|
<View style={[styles.header, { paddingTop: insets.top + spacing[3] }]}>
|
|
<Text style={styles.headerTitle}>{t('upload.title')}</Text>
|
|
<IconButton name="close" onPress={() => router.back()} />
|
|
</View>
|
|
|
|
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
|
{/* Source */}
|
|
<Label>{t('upload.source')}</Label>
|
|
<View style={styles.sourceRow}>
|
|
<SourceButton icon="scan-outline" label={t('upload.scan')} loadingLabel={t('upload.scanning')} loading={scanning} onPress={onScan} />
|
|
<SourceButton icon="document-outline" label={t('upload.chooseFile')} onPress={onChooseFile} />
|
|
<SourceButton icon="image-outline" label={t('upload.gallery')} onPress={onPickPhoto} />
|
|
</View>
|
|
<Text style={styles.scanHint}>
|
|
{t('upload.scanHint')}
|
|
</Text>
|
|
|
|
{/* Selected file */}
|
|
{file ? (
|
|
<View style={styles.fileCard}>
|
|
<FileGlyph mimeType={file.mimeType} />
|
|
<View style={{ flex: 1, marginLeft: spacing[3] }}>
|
|
<Text style={styles.fileName} numberOfLines={1}>
|
|
{file.name}
|
|
</Text>
|
|
<Text style={styles.fileMeta}>{formatBytes(file.sizeBytes)} · {t('upload.readyToUpload')}</Text>
|
|
</View>
|
|
<Pressable onPress={() => setFile(null)} hitSlop={8}>
|
|
<Ionicons name="close-circle" size={22} color={colors.slate400} />
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Name */}
|
|
<Field label={t('upload.name')} style={{ marginTop: spacing[5] }}>
|
|
<TextField value={name} onChangeText={setName} placeholder={t('upload.namePlaceholder')} />
|
|
</Field>
|
|
|
|
{/* Property */}
|
|
<Field label={t('upload.property')}>
|
|
<Pressable style={styles.select} onPress={() => setPickingProperty((v) => !v)}>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
|
<Ionicons name="home-outline" size={18} color={colors.teal600} />
|
|
<Text style={styles.selectValue}>{selectedProperty?.name ?? t('upload.selectProperty')}</Text>
|
|
</View>
|
|
<Ionicons name={pickingProperty ? 'chevron-up' : 'chevron-down'} size={18} color={colors.slate400} />
|
|
</Pressable>
|
|
{pickingProperty ? (
|
|
<View style={styles.selectList}>
|
|
{properties.map((p) => (
|
|
<Pressable
|
|
key={p.id}
|
|
style={styles.selectItem}
|
|
onPress={() => {
|
|
setPropertyId(p.id);
|
|
setPickingProperty(false);
|
|
}}
|
|
>
|
|
<Text style={styles.selectItemText}>{p.name}</Text>
|
|
{p.id === effectivePropertyId ? (
|
|
<Ionicons name="checkmark" size={18} color={colors.teal600} />
|
|
) : null}
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
</Field>
|
|
|
|
{/* Category */}
|
|
<Field label={t('upload.category')}>
|
|
<View style={styles.catWrap}>
|
|
{categories.map((c) => {
|
|
const active = c.slug === effectiveCategory;
|
|
return (
|
|
<Pressable
|
|
key={c.slug}
|
|
onPress={() => {
|
|
setCategory(c.slug);
|
|
// Requirements belong to a category, so the old pick is stale.
|
|
setRequirementId(null);
|
|
setPickingRequirement(false);
|
|
}}
|
|
style={[styles.catChip, active && { backgroundColor: categoryColor(c.slug) + '18', borderColor: categoryColor(c.slug) }]}
|
|
>
|
|
<View style={[styles.catDot, { backgroundColor: categoryColor(c.slug) }]} />
|
|
<Text style={[styles.catText, active && { color: categoryColor(c.slug) }]}>{c.name}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</Field>
|
|
|
|
{/* Requirement — the backend's real filing unit */}
|
|
{requirements.length > 0 ? (
|
|
<Field label={t('upload.requirement')}>
|
|
<Pressable style={styles.select} onPress={() => setPickingRequirement((v) => !v)}>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }}>
|
|
<Ionicons name="pricetag-outline" size={18} color={colors.teal600} />
|
|
<Text
|
|
style={[styles.selectValue, !selectedRequirement && { color: colors.textMuted2, fontWeight: '400' }]}
|
|
numberOfLines={1}
|
|
>
|
|
{selectedRequirement?.name ?? t('upload.selectRequirement')}
|
|
</Text>
|
|
</View>
|
|
<Ionicons
|
|
name={pickingRequirement ? 'chevron-up' : 'chevron-down'}
|
|
size={18}
|
|
color={colors.slate400}
|
|
/>
|
|
</Pressable>
|
|
{pickingRequirement ? (
|
|
<View style={styles.selectList}>
|
|
{requirements.map((r, i) => {
|
|
const newSection = i === 0 || requirements[i - 1].sectionName !== r.sectionName;
|
|
return (
|
|
<React.Fragment key={r.id}>
|
|
{newSection && r.sectionName ? (
|
|
<Text style={styles.sectionHeader}>{r.sectionName}</Text>
|
|
) : null}
|
|
<Pressable
|
|
style={styles.selectItem}
|
|
onPress={() => {
|
|
setRequirementId(r.id);
|
|
setPickingRequirement(false);
|
|
// The requirement names it, unless the user already did.
|
|
if (!name.trim()) setName(r.name);
|
|
}}
|
|
>
|
|
<Text style={styles.selectItemText} numberOfLines={2}>
|
|
{r.name}
|
|
</Text>
|
|
{r.id === requirementId ? (
|
|
<Ionicons name="checkmark" size={18} color={colors.teal600} />
|
|
) : null}
|
|
</Pressable>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</View>
|
|
) : null}
|
|
<Text style={styles.requirementHint}>{t('upload.requirementHint')}</Text>
|
|
</Field>
|
|
) : null}
|
|
|
|
{/* Expiry */}
|
|
<Field label={t('upload.expiry')} optional>
|
|
<DateField value={expiry} onChange={setExpiry} minimumDate={new Date()} />
|
|
</Field>
|
|
|
|
<Button
|
|
title={t('upload.submit')}
|
|
icon="cloud-upload-outline"
|
|
size="lg"
|
|
block
|
|
loading={uploadMutation.isPending}
|
|
onPress={onSubmit}
|
|
style={{ marginTop: spacing[3], marginBottom: insets.bottom + spacing[5] }}
|
|
/>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SourceButton({
|
|
icon,
|
|
label,
|
|
loadingLabel,
|
|
onPress,
|
|
loading,
|
|
}: {
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
label: string;
|
|
loadingLabel?: string;
|
|
onPress: () => void;
|
|
loading?: boolean;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onPress={loading ? undefined : onPress}
|
|
style={({ pressed }) => [styles.sourceBtn, pressed && { opacity: 0.85 }]}
|
|
>
|
|
<View style={styles.sourceIcon}>
|
|
<Ionicons name={icon} size={22} color={colors.teal600} />
|
|
</View>
|
|
<Text style={styles.sourceLabel}>
|
|
{loading ? loadingLabel ?? label : label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: spacing[4],
|
|
paddingBottom: spacing[3],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
headerTitle: { fontSize: fontSize.lg, fontWeight: '700', color: colors.ink },
|
|
body: { padding: spacing[4] },
|
|
sourceRow: { flexDirection: 'row', gap: spacing[3], marginTop: spacing[2] },
|
|
sourceBtn: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
paddingVertical: spacing[5],
|
|
borderRadius: radii.lg,
|
|
borderWidth: 1.5,
|
|
borderColor: colors.border,
|
|
borderStyle: 'dashed',
|
|
backgroundColor: colors.surface,
|
|
},
|
|
sourceIcon: {
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 12,
|
|
backgroundColor: colors.teal50,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
sourceLabel: { fontSize: fontSize.sm, fontWeight: '600', color: colors.ink },
|
|
scanHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: spacing[3], textAlign: 'center' },
|
|
fileCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.surfaceSunken,
|
|
borderRadius: radii.md,
|
|
padding: spacing[3],
|
|
marginTop: spacing[5],
|
|
},
|
|
fileName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.ink },
|
|
fileMeta: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 2 },
|
|
select: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: radii.md,
|
|
paddingHorizontal: spacing[4],
|
|
minHeight: 50,
|
|
},
|
|
selectValue: { fontSize: fontSize.md, fontWeight: '600', color: colors.ink },
|
|
selectList: {
|
|
marginTop: 6,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: radii.md,
|
|
overflow: 'hidden',
|
|
},
|
|
selectItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: spacing[4],
|
|
paddingVertical: spacing[3],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
selectItemText: { fontSize: fontSize.base, color: colors.ink, flex: 1, marginRight: spacing[2] },
|
|
sectionHeader: {
|
|
fontSize: fontSize['2xs'],
|
|
fontWeight: '700',
|
|
color: colors.slate500,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
paddingHorizontal: spacing[4],
|
|
paddingTop: spacing[3],
|
|
paddingBottom: 4,
|
|
backgroundColor: colors.surfaceSunken,
|
|
},
|
|
requirementHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 6 },
|
|
catWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
|
catChip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 7,
|
|
paddingHorizontal: 12,
|
|
height: 36,
|
|
borderRadius: radii.pill,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surface,
|
|
},
|
|
catDot: { width: 8, height: 8, borderRadius: 4 },
|
|
catText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600 },
|
|
});
|