299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Alert, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { api, DocCategory, Document } from '@/api';
|
|
import { useT } from '@/i18n';
|
|
import { AppBar } from '@/components/AppBar';
|
|
import { Badge, Button, Card, IconButton, Loading } from '@/components/ui';
|
|
import { CategoryPill, ExpiryBadge } from '@/components/domain';
|
|
import { PdfView } from '@/components/PdfView';
|
|
import { getLocalFile, openInSystemViewer, shareDocument } from '@/lib/documentFile';
|
|
import { formatBytes, formatDate } from '@/lib/format';
|
|
import { colors, fontSize, radii, spacing } from '@/theme';
|
|
|
|
export default function DocumentDetailScreen() {
|
|
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();
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [busy, setBusy] = useState<'open' | 'share' | null>(null);
|
|
|
|
// Prefer the cached list entry; fetch only if we arrived without one.
|
|
const listQuery = useQuery({
|
|
queryKey: ['documents', pid],
|
|
queryFn: () => api.documents.listByProperty(pid as number),
|
|
enabled: pid != null,
|
|
});
|
|
const cached = listQuery.data?.find((d: Document) => d.id === docId);
|
|
const singleQuery = useQuery({
|
|
queryKey: ['document', docId],
|
|
queryFn: () => api.documents.get(docId),
|
|
enabled: !cached && pid == null,
|
|
});
|
|
const doc: Document | undefined = cached ?? singleQuery.data;
|
|
|
|
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
|
|
const catName = (slug: string) =>
|
|
catsQuery.data?.find((c: DocCategory) => c.slug === slug)?.name ?? slug;
|
|
|
|
// Downloaded with the bearer header, for the preview and the phone's viewer.
|
|
const fileQuery = useQuery({
|
|
queryKey: ['document-file', docId],
|
|
queryFn: () => getLocalFile(doc as Document),
|
|
enabled: !!doc?.fileUri,
|
|
retry: false,
|
|
});
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: () => api.documents.remove(docId),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['documents'] });
|
|
qc.invalidateQueries({ queryKey: ['all-documents'] });
|
|
qc.invalidateQueries({ queryKey: ['properties'] });
|
|
router.back();
|
|
},
|
|
});
|
|
|
|
function confirmDelete() {
|
|
Alert.alert(t('document.deleteTitle'), t('document.deleteBody'), [
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{
|
|
text: t('common.delete'),
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
setDeleting(true);
|
|
removeMutation.mutate(undefined, { onSettled: () => setDeleting(false) });
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
async function onOpen() {
|
|
if (!doc) return;
|
|
setBusy('open');
|
|
try {
|
|
await openInSystemViewer(doc);
|
|
} catch (e: any) {
|
|
Alert.alert(t('document.cannotOpen'), e?.message ?? t('document.cannotOpenBody'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
async function onShare() {
|
|
if (!doc) return;
|
|
setBusy('share');
|
|
try {
|
|
await shareDocument(doc);
|
|
} catch (e: any) {
|
|
Alert.alert(t('document.cannotSave'), e?.message ?? t('document.cannotSaveBody'));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
if (!doc) {
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
|
|
<AppBar title={t('document.title')} back />
|
|
<Loading />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const isImage = doc.mimeType.startsWith('image/');
|
|
const fileReady = !!fileQuery.data;
|
|
const fileError = fileQuery.error as Error | undefined;
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
|
|
<AppBar
|
|
title={t('document.title')}
|
|
back
|
|
right={
|
|
<IconButton
|
|
name="create-outline"
|
|
onPress={() => router.push(`/document/edit/${docId}?propertyId=${pid ?? ''}`)}
|
|
/>
|
|
}
|
|
/>
|
|
<ScrollView contentContainerStyle={styles.body}>
|
|
{doc.expiryStatus === 'expiring' || doc.expiryStatus === 'expired' ? (
|
|
<Card
|
|
// The banner is about the expiry date; go where it can be changed.
|
|
onPress={() => router.push(`/document/edit/${docId}?propertyId=${pid ?? ''}`)}
|
|
style={[
|
|
styles.banner,
|
|
doc.expiryStatus === 'expired'
|
|
? { backgroundColor: colors.dangerBg, borderColor: '#f0c2c9' }
|
|
: { backgroundColor: colors.warningBg, borderColor: '#f0dcae' },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={doc.expiryStatus === 'expired' ? 'alert-circle-outline' : 'time-outline'}
|
|
size={20}
|
|
color={doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText}
|
|
/>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={[styles.bannerTitle, { color: doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText }]}>
|
|
{doc.expiryStatus === 'expired' ? t('document.expired') : t('document.expiringSoon')}
|
|
</Text>
|
|
<Text style={[styles.bannerText, { color: doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText }]}>
|
|
{doc.expiryDate ? t('document.expiresOn', { date: formatDate(doc.expiryDate) }) : t('document.checkExpiry')}
|
|
</Text>
|
|
</View>
|
|
<Ionicons
|
|
name="chevron-forward"
|
|
size={18}
|
|
color={doc.expiryStatus === 'expired' ? colors.dangerText : colors.warningText}
|
|
/>
|
|
</Card>
|
|
) : null}
|
|
|
|
{/* Preview — always from the downloaded local copy, never a raw URL. */}
|
|
<View style={styles.preview}>
|
|
{fileQuery.isLoading ? (
|
|
<View style={styles.previewBox}>
|
|
<Loading label={t('document.loadingFile')} />
|
|
</View>
|
|
) : fileError ? (
|
|
<View style={styles.previewBox}>
|
|
<Ionicons name="cloud-offline-outline" size={30} color={colors.slate400} />
|
|
<Text style={styles.previewErr}>{fileError.message}</Text>
|
|
</View>
|
|
) : isImage && fileReady ? (
|
|
<Image source={{ uri: fileQuery.data }} style={styles.previewImage} resizeMode="contain" />
|
|
) : fileReady ? (
|
|
<PdfView ready onPress={onOpen} style={{ width: '100%' }} />
|
|
) : (
|
|
<SkeletonPage />
|
|
)}
|
|
{doc.pageCount ? (
|
|
<View style={styles.pageBadge}>
|
|
<Badge label={t('document.pages', { count: doc.pageCount })} tone="neutral" />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
<Text style={styles.name}>{doc.name}</Text>
|
|
<View style={styles.tagRow}>
|
|
<CategoryPill slug={doc.category} name={catName(doc.category)} />
|
|
<ExpiryBadge document={doc} />
|
|
</View>
|
|
|
|
<Card style={{ marginTop: spacing[4] }}>
|
|
<View style={styles.metaGrid}>
|
|
<Meta k={t('document.uploaded')} v={formatDate(doc.uploadedAt)} />
|
|
<Meta
|
|
k={t('document.expires')}
|
|
v={doc.expiryDate ? formatDate(doc.expiryDate) : t('editDocument.noExpiry')}
|
|
/>
|
|
<Meta k={t('document.fileSize')} v={formatBytes(doc.sizeBytes)} />
|
|
<Meta k={t('document.format')} v={isImage ? t('document.formatImage') : t('document.formatPdf')} />
|
|
</View>
|
|
</Card>
|
|
|
|
<View style={styles.actions}>
|
|
<Button
|
|
title={t('document.share')}
|
|
variant="ghost"
|
|
icon="share-outline"
|
|
style={{ flex: 1 }}
|
|
loading={busy === 'share'}
|
|
disabled={!fileReady}
|
|
onPress={onShare}
|
|
/>
|
|
<Button
|
|
title={t('common.delete')}
|
|
variant="danger"
|
|
icon="trash-outline"
|
|
style={{ flex: 1 }}
|
|
loading={deleting}
|
|
onPress={confirmDelete}
|
|
/>
|
|
</View>
|
|
|
|
<Button
|
|
title={t('document.openOnPhone')}
|
|
icon="phone-portrait-outline"
|
|
block
|
|
loading={busy === 'open'}
|
|
disabled={!fileReady}
|
|
onPress={onOpen}
|
|
style={{ marginTop: spacing[3] }}
|
|
/>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Meta({ k, v }: { k: string; v: string }) {
|
|
return (
|
|
<View style={styles.metaItem}>
|
|
<Text style={styles.metaK}>{k}</Text>
|
|
<Text style={styles.metaV}>{v}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SkeletonPage() {
|
|
return (
|
|
<View style={styles.skeleton}>
|
|
{[0.9, 0.6, 0.95, 0.8, 0.95, 0.7, 0.85].map((w, i) => (
|
|
<View key={i} style={[styles.skelBar, { width: `${w * 100}%` }, i === 0 && { height: 12, marginBottom: 12 }]} />
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
body: { padding: spacing[4], paddingBottom: spacing[9] },
|
|
banner: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing[3], marginBottom: spacing[4] },
|
|
bannerTitle: { fontSize: fontSize.md, fontWeight: '700' },
|
|
bannerText: { fontSize: fontSize.sm, opacity: 0.85, marginTop: 2 },
|
|
preview: { alignItems: 'center', justifyContent: 'center', marginBottom: spacing[4] },
|
|
previewBox: {
|
|
width: '100%',
|
|
height: 220,
|
|
borderRadius: radii.md,
|
|
backgroundColor: colors.surface,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: spacing[2],
|
|
padding: spacing[5],
|
|
},
|
|
previewErr: { fontSize: fontSize.sm, color: colors.textMuted, textAlign: 'center' },
|
|
previewImage: {
|
|
width: '100%',
|
|
height: 320,
|
|
borderRadius: radii.md,
|
|
backgroundColor: colors.surface,
|
|
},
|
|
pageBadge: { marginTop: spacing[3] },
|
|
name: { fontSize: fontSize.xl, fontWeight: '700', color: colors.ink },
|
|
tagRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: spacing[2] },
|
|
metaGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
metaItem: { width: '50%', paddingVertical: spacing[2] },
|
|
metaK: { fontSize: fontSize.xs, color: colors.textMuted2, textTransform: 'uppercase', letterSpacing: 0.3 },
|
|
metaV: { fontSize: fontSize.base, color: colors.ink, fontWeight: '600', marginTop: 2 },
|
|
actions: { flexDirection: 'row', gap: spacing[3], marginTop: spacing[4] },
|
|
skeleton: {
|
|
width: 230,
|
|
height: 300,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: radii.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
padding: spacing[5],
|
|
justifyContent: 'flex-start',
|
|
},
|
|
skelBar: { height: 8, borderRadius: 4, backgroundColor: colors.surfaceSunken, marginBottom: 9 },
|
|
});
|