initial
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { api, DocCategory, Document, LocalFile } from '@/api';
|
||||
import { useT } from '@/i18n';
|
||||
import { AppBar } from '@/components/AppBar';
|
||||
import { Button, Field, Loading, TextField } from '@/components/ui';
|
||||
import { DateField } from '@/components/DateField';
|
||||
import { FileGlyph } from '@/components/domain';
|
||||
import { clearCachedFile } from '@/lib/documentFile';
|
||||
import { pickImageFromLibrary } from '@/lib/pickImage';
|
||||
import { isCameraBlocked, openPermissionSettings } from '@/lib/permissions';
|
||||
import { scanDocumentToPdf } from '@/scanner/scanToPdf';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { categoryColor, colors, fontSize, radii, spacing } from '@/theme';
|
||||
|
||||
export default function EditDocumentScreen() {
|
||||
const { id, propertyId } = useLocalSearchParams<{ id: string; propertyId?: string }>();
|
||||
const docId = Number(id);
|
||||
const pid = propertyId ? Number(propertyId) : undefined;
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const t = useT();
|
||||
|
||||
// From the property's cached list: the backend has no per-file GET.
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['documents', pid],
|
||||
queryFn: () => api.documents.listByProperty(pid as number),
|
||||
enabled: pid != null,
|
||||
});
|
||||
const doc: Document | undefined = listQuery.data?.find((d: Document) => d.id === docId);
|
||||
|
||||
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
|
||||
const categories: DocCategory[] = catsQuery.data ?? [];
|
||||
|
||||
const [expiry, setExpiry] = useState<string | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState<string>('');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc || loaded) return;
|
||||
setExpiry(doc.expiryDate ?? null);
|
||||
setNote(doc.note ?? '');
|
||||
setTitle(doc.name ?? '');
|
||||
setCategory(doc.category);
|
||||
setLoaded(true);
|
||||
}, [doc, loaded]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.documents.update({
|
||||
id: docId,
|
||||
title: title.trim() || null,
|
||||
expiryDate: expiry,
|
||||
note: note.trim() || null,
|
||||
// Re-filing costs an extra backend lookup, so only send it if changed.
|
||||
...(doc && category !== doc.category ? { category } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['documents'] });
|
||||
qc.invalidateQueries({ queryKey: ['all-documents'] });
|
||||
router.back();
|
||||
},
|
||||
onError: (e: any) => Alert.alert(t('editDocument.failed'), e?.message ?? t('common.tryAgain')),
|
||||
});
|
||||
|
||||
// A separate endpoint from the metadata update, so it applies immediately.
|
||||
async function replaceWith(file: LocalFile | null) {
|
||||
if (!file || !doc) return;
|
||||
setReplacing(true);
|
||||
try {
|
||||
await api.documents.replaceFile(doc.id, file);
|
||||
await clearCachedFile(doc); // otherwise the old copy stays on screen
|
||||
qc.invalidateQueries({ queryKey: ['documents'] });
|
||||
qc.invalidateQueries({ queryKey: ['all-documents'] });
|
||||
qc.removeQueries({ queryKey: ['document-file', doc.id] });
|
||||
Alert.alert(t('editDocument.replaced'));
|
||||
} catch (e: any) {
|
||||
Alert.alert(t('editDocument.replaceFailed'), e?.message ?? t('common.tryAgain'));
|
||||
} finally {
|
||||
setReplacing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceByScan() {
|
||||
try {
|
||||
const result = await scanDocumentToPdf(doc?.name || t('upload.scannedName'));
|
||||
await replaceWith(result?.file ?? null);
|
||||
} catch (e: any) {
|
||||
if (await isCameraBlocked()) openPermissionSettings('permissions.cameraBody');
|
||||
else Alert.alert(t('upload.scannerUnavailable'), e?.message ?? t('upload.scannerUnavailableBody'));
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceByFile() {
|
||||
const res = await DocumentPicker.getDocumentAsync({
|
||||
type: ['application/pdf', 'image/*'],
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
if (res.canceled || !res.assets?.length) return;
|
||||
const a = res.assets[0];
|
||||
await replaceWith({
|
||||
uri: a.uri,
|
||||
name: a.name,
|
||||
mimeType: a.mimeType ?? 'application/octet-stream',
|
||||
sizeBytes: a.size ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceByPhoto() {
|
||||
await replaceWith(await pickImageFromLibrary({ quality: 0.9, fallbackName: 'photo' }));
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
|
||||
<AppBar title={t('editDocument.title')} back />
|
||||
<Loading />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: colors.bgApp }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<AppBar title={t('editDocument.title')} subtitle={doc.name} back />
|
||||
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
||||
{/* Current file + replacement */}
|
||||
<Field label={t('editDocument.file')}>
|
||||
<View style={styles.fileCard}>
|
||||
<FileGlyph mimeType={doc.mimeType} size={38} />
|
||||
<View style={{ flex: 1, marginLeft: spacing[3] }}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{doc.name}
|
||||
</Text>
|
||||
<Text style={styles.fileMeta}>{formatBytes(doc.sizeBytes)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.replaceRow}>
|
||||
<ReplaceButton icon="scan-outline" label={t('upload.scan')} onPress={replaceByScan} busy={replacing} />
|
||||
<ReplaceButton icon="document-outline" label={t('upload.chooseFile')} onPress={replaceByFile} busy={replacing} />
|
||||
<ReplaceButton icon="image-outline" label={t('upload.gallery')} onPress={replaceByPhoto} busy={replacing} />
|
||||
</View>
|
||||
<Text style={styles.replaceHint}>{t('editDocument.replaceHint')}</Text>
|
||||
</Field>
|
||||
|
||||
<Field label={t('editDocument.name')}>
|
||||
<TextField
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder={t('editDocument.namePlaceholder')}
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('upload.expiry')} optional>
|
||||
<DateField
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
placeholder={t('editDocument.noExpiry')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('upload.category')}>
|
||||
<View style={styles.catWrap}>
|
||||
{categories.map((c) => {
|
||||
const active = c.slug === category;
|
||||
return (
|
||||
<Pressable
|
||||
key={c.slug}
|
||||
onPress={() => setCategory(c.slug)}
|
||||
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>
|
||||
|
||||
<Field label={t('editDocument.note')} optional>
|
||||
<TextField
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
placeholder={t('editDocument.notePlaceholder')}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
style={{ minHeight: 90, textAlignVertical: 'top' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Text style={styles.hint}>{t('editDocument.hint')}</Text>
|
||||
|
||||
<Button
|
||||
title={t('editDocument.save')}
|
||||
icon="checkmark"
|
||||
size="lg"
|
||||
block
|
||||
loading={save.isPending}
|
||||
onPress={() => save.mutate()}
|
||||
style={{ marginTop: spacing[2] }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplaceButton({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
busy,
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={busy ? undefined : onPress}
|
||||
style={({ pressed }) => [styles.replaceBtn, (pressed || busy) && { opacity: 0.6 }]}
|
||||
>
|
||||
<Ionicons name={icon} size={20} color={colors.teal600} />
|
||||
<Text style={styles.replaceLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: { padding: spacing[4], paddingBottom: spacing[9] },
|
||||
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 },
|
||||
hint: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing[3] },
|
||||
fileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surfaceSunken,
|
||||
borderRadius: radii.md,
|
||||
padding: spacing[3],
|
||||
},
|
||||
fileName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.ink },
|
||||
fileMeta: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 2 },
|
||||
replaceRow: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[3] },
|
||||
replaceBtn: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: spacing[3],
|
||||
borderRadius: radii.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
replaceLabel: { fontSize: fontSize.xs, fontWeight: '600', color: colors.ink },
|
||||
replaceHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: spacing[2] },
|
||||
});
|
||||
Reference in New Issue
Block a user