Files
casadoc-app/app/document/[id].tsx
T
2026-08-26 23:01:07 +03:00

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