Files
2026-08-26 23:01:07 +03:00

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 },
});