Первая версия мобильного приложения

This commit is contained in:
mi
2026-08-21 17:37:12 +03:00
parent d2415fcfeb
commit c1e49fb15d
60 changed files with 14106 additions and 6 deletions
+284
View File
@@ -0,0 +1,284 @@
import { Feather } from "@expo/vector-icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { ApiError } from "../../src/api";
import { useApp } from "../../src/app-context";
import { ScreenShell } from "../../src/components/ScreenShell";
import { notificationApi, notificationKeys, uploadDraftApi } from "../../src/notification-api";
import { formatNotificationPrice, typeMap } from "../../src/notification-presenter";
import { downloadAndOpen, pickFiles, type NativeFile } from "../../src/native-files";
import { publicApi } from "../../src/services";
import { colors, radii, spacing } from "../../src/theme";
import type { NotificationButton, UploadDraft } from "../../src/types";
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
export default function NotificationDetailScreen() {
const { id = "" } = useLocalSearchParams<{ id: string }>();
const { authStatus } = useApp();
const authenticated = authStatus === "authenticated";
const router = useRouter();
const client = useQueryClient();
const readSent = useRef(false);
const [error, setError] = useState<unknown>();
const detail = useQuery({
queryKey: notificationKeys.detail(id),
queryFn: () => notificationApi.detail(id),
enabled: authenticated && Boolean(id),
retry: (count, reason) => !(reason instanceof ApiError && reason.status === 404) && count < 1,
});
const catalog = useQuery({
queryKey: notificationKeys.catalog,
queryFn: notificationApi.catalog,
staleTime: Infinity,
});
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
const type = detail.data ? byCode.get(detail.data.notification_type) : undefined;
const canUpload = Boolean(detail.data?.details?.send_documents);
const drafts = useQuery({
queryKey: ["uploads", "notification", id],
queryFn: () => uploadDraftApi.list(id),
enabled: authenticated && Boolean(id) && canUpload,
});
const pending = drafts.data ?? detail.data?.details?.pending_documents ?? [];
const closed = detail.data?.lifecycle_status === "closed";
useEffect(() => {
if (!detail.data || readSent.current || detail.data.is_read !== false) return;
readSent.current = true;
void notificationApi.read(id).then((state) => {
client.setQueryData(notificationKeys.detail(id), { ...detail.data, ...state });
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
}).catch(setError);
}, [client, detail.data, id]);
const pressButton = useMutation({
mutationFn: (button: NotificationButton) => notificationApi.button(id, button.code),
onSuccess: async (state) => {
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
await client.invalidateQueries({ queryKey: ["notifications"] });
router.replace("/notifications");
},
onError: setError,
});
const removeDraft = useMutation({
mutationFn: uploadDraftApi.remove,
onSuccess: () => client.invalidateQueries({ queryKey: ["uploads", "notification", id] }),
onError: setError,
});
const chooseFile = async () => {
try {
const files = await pickFiles({
multiple: true,
mimeTypes: config.data?.attachments.allowed_mime_types,
});
if (files.length) await uploadFiles(files);
} catch (reason) {
setError(reason);
}
};
const uploadFiles = async (files: NativeFile[]) => {
const limits = config.data?.attachments;
const maxBytes = (limits?.max_size_mb ?? 5) * 1024 * 1024;
const allowed = limits?.allowed_mime_types ?? [];
if (pending.length + files.length > 10) {
setError(new Error("К одному уведомлению можно приложить не более 10 файлов."));
return;
}
const invalid = files.find((file) => file.size > maxBytes || (allowed.length > 0 && !allowed.includes(file.mimeType)));
if (invalid) {
setError(new Error(`Файл «${invalid.name}» имеет недопустимый тип или размер.`));
return;
}
setError(undefined);
try {
for (const file of files) await uploadDraftApi.upload(id, file);
await drafts.refetch();
await detail.refetch();
} catch (reason) {
setError(reason);
}
};
const download = async (documentId: string) => {
setError(undefined);
try {
const result = await notificationApi.documentUrl(id, documentId);
const title = detail.data?.details?.documents?.find((item) => item.document_id === documentId)?.title;
await downloadAndOpen(result.download_url, title ?? "document");
await Promise.all([detail.refetch(), client.invalidateQueries({ queryKey: ["notifications"] })]);
} catch (reason) {
setError(reason);
}
};
if (!authenticated) {
return (
<ScreenShell>
<DetailHeader title="Уведомление" onBack={() => router.replace("/notifications")} />
<View style={local.center}>
<Text style={styles.text}>Для просмотра уведомления требуется авторизация.</Text>
<Button title="Перейти в Центр" onPress={() => router.replace("/notifications")} />
</View>
</ScreenShell>
);
}
const unavailable = detail.error instanceof ApiError && detail.error.status === 404;
const notification = detail.data;
const details = notification?.details;
const price = formatNotificationPrice(notification?.price);
const oldPrice = formatNotificationPrice(notification?.old_price);
return (
<ScreenShell>
<DetailHeader title={type?.label ?? "Уведомление"} onBack={() => router.back()} />
<ScrollView contentContainerStyle={local.content}>
{(detail.isLoading || catalog.isLoading) && <Loading />}
{unavailable ? (
<View style={local.empty}>
<Feather name="slash" size={32} color={colors.mutedForeground} />
<Text style={styles.title}>Уведомление недоступно</Text>
<Text style={styles.muted}>Возможно, оно уже закрыто или было удалено.</Text>
</View>
) : (detail.error || catalog.error) ? (
<ErrorNotice error={detail.error ?? catalog.error} retry={() => { void detail.refetch(); void catalog.refetch(); }} />
) : notification ? (
<>
{closed && <Text style={styles.error}>Уведомление больше не актуально. Действия недоступны.</Text>}
{details?.deadline ? (
<View style={local.deadline}>
<Feather name="clock" size={16} color={colors.warning} />
<Text style={styles.text}>Срок: {new Date(details.deadline).toLocaleString("ru-RU")}</Text>
</View>
) : null}
<Text accessibilityRole="header" style={styles.title}>{details?.details_header ?? notification.header}</Text>
{details?.details_text || notification.text ? <Text style={styles.text}>{details?.details_text ?? notification.text}</Text> : null}
{price ? (
<View style={local.priceRow}>
<Text style={local.price}>{price}</Text>
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
</View>
) : null}
{details?.todo_header ? <Text style={styles.heading}>{details.todo_header}</Text> : null}
{details?.todo_plan?.map((step) => (
<View key={`${step.number}-${step.text}`} style={local.step}>
<View style={local.stepNumber}><Text style={local.stepNumberText}>{step.number}</Text></View>
<Text style={[styles.text, { flex: 1 }]}>{step.text}</Text>
</View>
))}
{details?.documents?.length ? (
<View style={local.block}>
<Text style={styles.heading}>Документы</Text>
{details.documents.map((document) => (
<Pressable key={document.document_id} onPress={() => void download(document.document_id)} style={local.file}>
<Feather name="file-text" size={20} color={colors.info} />
<View style={{ flex: 1 }}>
<Text style={styles.text}>{document.title}</Text>
<Text style={styles.muted}>{formatBytes(document.size_bytes)}</Text>
</View>
<Feather name="download" size={18} color={colors.foreground} />
</Pressable>
))}
</View>
) : null}
{canUpload ? (
<View style={local.block}>
<Text style={styles.heading}>Приложить документы</Text>
<Text style={styles.muted}>Черновики сохраняются, пока вы не отправите или не удалите их.</Text>
{drafts.isLoading && <Loading />}
{pending.map((draft) => (
<DraftRow
key={draft.draft_id}
draft={draft}
disabled={removeDraft.isPending || closed}
onRemove={() => removeDraft.mutate(draft.draft_id)}
/>
))}
<Button title="Добавить файлы" secondary disabled={closed || pending.length >= 10} onPress={() => void chooseFile()} />
</View>
) : null}
<View style={local.buttons}>
{type?.button_primary ? (
<Button
title={type.button_primary.label}
disabled={closed || pressButton.isPending || (type.button_primary.code === "send_docs" && !pending.some((draft) => draft.scan_status === "clean"))}
onPress={() => pressButton.mutate(type.button_primary!)}
/>
) : null}
{type?.button_secondary ? (
<Button
secondary
title={type.button_secondary.label}
disabled={closed || pressButton.isPending}
onPress={() => pressButton.mutate(type.button_secondary!)}
/>
) : null}
</View>
{error && <ErrorNotice error={error} />}
</>
) : null}
</ScrollView>
</ScreenShell>
);
}
function DetailHeader({ title, onBack }: { title: string; onBack: () => void }) {
return (
<View style={local.header}>
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={onBack} style={local.back}>
<Feather name="arrow-left" size={20} color={colors.foreground} />
</Pressable>
<Text numberOfLines={1} style={[styles.heading, { flex: 1 }]}>{title}</Text>
</View>
);
}
function DraftRow({ draft, disabled, onRemove }: { draft: UploadDraft; disabled: boolean; onRemove: () => void }) {
const title = draft.title;
const status = {
pending: "Проверяется",
clean: "Готов к отправке",
infected: "Файл отклонён",
failed: "Ошибка проверки",
}[draft.scan_status];
return (
<View style={local.file}>
<Feather name={draft.scan_status === "clean" ? "check-circle" : "file"} size={20} color={draft.scan_status === "clean" ? colors.success : colors.warning} />
<View style={{ flex: 1 }}>
<Text style={styles.text}>{title}</Text>
<Text style={styles.muted}>{status} · {formatBytes(draft.size_bytes)}</Text>
</View>
<Pressable disabled={disabled} accessibilityLabel={`Удалить ${title}`} onPress={onRemove}>
<Feather name="trash-2" size={18} color={colors.destructive} />
</Pressable>
</View>
);
}
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
const local = StyleSheet.create({
header: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.lg, borderBottomWidth: 1, borderBottomColor: colors.border },
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
center: { flex: 1, justifyContent: "center", gap: spacing.md, padding: spacing.lg },
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.sm, borderRadius: radii.md, backgroundColor: "#fff7df", padding: spacing.md },
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
price: { fontSize: 22, fontWeight: "700", color: colors.foreground },
oldPrice: { fontSize: 14, color: colors.mutedForeground, textDecorationLine: "line-through" },
step: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md },
stepNumber: { width: 28, height: 28, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
stepNumberText: { color: colors.primaryForeground, fontSize: 13, fontWeight: "700" },
block: { gap: spacing.sm, paddingVertical: spacing.sm },
file: { flexDirection: "row", alignItems: "center", gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, backgroundColor: colors.card },
buttons: { gap: spacing.sm, paddingTop: spacing.sm },
});