initial
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user