diff --git a/mobileapp/__tests__/BatchReceiptExport.test.tsx b/mobileapp/__tests__/BatchReceiptExport.test.tsx new file mode 100644 index 0000000..dc05019 --- /dev/null +++ b/mobileapp/__tests__/BatchReceiptExport.test.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { Share } from "react-native"; +import { render, fireEvent, waitFor } from "@testing-library/react-native"; +import BatchDetailScreen from "../app/transaction/batch/[id]"; +import { captureRef } from "react-native-view-shot"; + +jest.mock("react-native-view-shot", () => ({ + captureRef: jest.fn(() => Promise.resolve("file:///tmp/zaps-receipt.png")), +})); + +jest.mock("expo-router", () => ({ + useLocalSearchParams: () => ({ id: "BATCH-001" }), + useRouter: () => ({ back: jest.fn() }), + Stack: { + Screen: () => null, + }, +})); + +describe("BatchReceiptExport", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(Share, "share") + .mockResolvedValue({ action: "sharedAction" } as any); + }); + + it("renders the export/share button in the header", () => { + const { getByLabelText } = render(); + expect(getByLabelText("Export receipt")).toBeTruthy(); + }); + + it("captures the receipt as a PNG and opens the share sheet", async () => { + const { getByLabelText } = render(); + fireEvent.press(getByLabelText("Export receipt")); + + await waitFor(() => { + expect(captureRef).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ format: "png", result: "tmpfile" }) + ); + }); + await waitFor(() => { + expect(Share.share).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("zaps-receipt.png"), + message: expect.stringContaining("BATCH-001"), + }) + ); + }); + }); + + it("shows a loading indicator while exporting", async () => { + (captureRef as jest.Mock).mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve("file:///tmp/r.png"), 50) + ) + ); + const { getByLabelText, getByTestId } = render( + + ); + fireEvent.press(getByLabelText("Export receipt")); + expect(getByTestId("export-spinner")).toBeTruthy(); + }); +}); diff --git a/mobileapp/app/transaction/batch/[id].tsx b/mobileapp/app/transaction/batch/[id].tsx index 7ea72c1..fd50080 100644 --- a/mobileapp/app/transaction/batch/[id].tsx +++ b/mobileapp/app/transaction/batch/[id].tsx @@ -8,11 +8,18 @@ import { SafeAreaView, FlatList, Animated, + Share, + Platform, + ActivityIndicator, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams, useRouter, Stack } from "expo-router"; +import { captureRef } from "react-native-view-shot"; import { COLORS } from "../../../src/constants/colors"; -import { BatchPayoutItem, BatchPayoutSummary } from "../../../src/types/batchPayout"; +import { + BatchPayoutItem, + BatchPayoutSummary, +} from "../../../src/types/batchPayout"; type BatchStep = "pending" | "parsing" | "initiating" | "completed"; @@ -198,13 +205,14 @@ function AnimatedProgressBar({ }, [pct, isComplete, animatedPct]); // Interpolate from the 0-100 animated value to pixel width. - const fillWidth = trackWidth > 0 - ? animatedPct.interpolate({ - inputRange: [0, 100], - outputRange: [0, trackWidth], - extrapolate: "clamp", - }) - : undefined; + const fillWidth = + trackWidth > 0 + ? animatedPct.interpolate({ + inputRange: [0, 100], + outputRange: [0, trackWidth], + extrapolate: "clamp", + }) + : undefined; // Fill colour transitions from primary to secondary green at completion. const fillColor = animatedPct.interpolate({ @@ -244,7 +252,10 @@ function StatusBadge({ status }: { status: BatchPayoutItem["status"] }) { pending: "#F59E0B", failed: "#EF4444", }; - const iconMap: Record = { + const iconMap: Record< + BatchPayoutItem["status"], + keyof typeof Ionicons.glyphMap + > = { completed: "checkmark-circle", pending: "time", failed: "close-circle", @@ -252,10 +263,7 @@ function StatusBadge({ status }: { status: BatchPayoutItem["status"] }) { return ( @@ -291,7 +299,12 @@ export default function BatchDetailScreen() { const router = useRouter(); const [summary] = useState(MOCK_SUMMARY); const [items] = useState(MOCK_ITEMS); - const [filter, setFilter] = useState<"all" | "completed" | "pending" | "failed">("all"); + const [filter, setFilter] = useState< + "all" | "completed" | "pending" | "failed" + >("all"); + const [exporting, setExporting] = useState(false); + + const receiptRef = useRef(null); const currentStep: BatchStep = useMemo(() => { if (summary.failedCount > 0) return "completed"; @@ -302,23 +315,63 @@ export default function BatchDetailScreen() { }, [summary]); const progress = useMemo( - () => (summary.itemCount > 0 ? (summary.completedCount / summary.itemCount) * 100 : 0), - [summary], + () => + summary.itemCount > 0 + ? (summary.completedCount / summary.itemCount) * 100 + : 0, + [summary] ); const filteredItems = useMemo( () => (filter === "all" ? items : items.filter((i) => i.status === filter)), - [items, filter], + [items, filter] ); const successRate = useMemo( () => summary.itemCount > 0 - ? ((summary.completedCount / (summary.itemCount - summary.failedCount)) * 100).toFixed(1) + ? ( + (summary.completedCount / + (summary.itemCount - summary.failedCount)) * + 100 + ).toFixed(1) : "0", - [summary], + [summary] ); + /** + * #694 — Transaction receipt export + * + * Captures the hidden receipt container via react-native-view-shot and + * hands the resulting PNG over to the system share sheet. Works for both + * completed batches and (once wired to real data) P2P transfers. + */ + const handleExportReceipt = async () => { + if (!receiptRef.current) return; + try { + setExporting(true); + const uri = await captureRef(receiptRef, { + format: "png", + quality: 1, + result: "tmpfile", + width: 680, + }); + await Share.share({ + url: Platform.OS === "ios" ? uri : `file://${uri}`, + message: `ZAPS batch receipt #${summary.id}`, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Could not export receipt"; + ( + globalThis as unknown as { + toast?: { error: (message: string) => void }; + } + ).toast?.error(msg); + } finally { + setExporting(false); + } + }; + const filters = [ { key: "all" as const, label: "All" }, { key: "completed" as const, label: "Success" }, @@ -335,7 +388,22 @@ export default function BatchDetailScreen() { Batch Details - + + {exporting ? ( + + ) : ( + + )} + + + {/* Hidden receipt container — captured via react-native-view-shot (#694). + Rendered off-screen so it never flashes on device but stays capturable. */} + + + + ZAPS + Batch Receipt + + + + + + Batch ID + #{summary.id} + + + Issued + + {new Date(summary.createdAt).toLocaleString()} + + + + + + + Total Payout + + {summary.totalAmount} {summary.currency} + + + + + Recipients + {summary.itemCount} + + + Completed + + {summary.completedCount} + + + + Failed + + {summary.failedCount} + + + + Success Rate + {successRate}% + + + + + Generated by ZAPS mobile app + + ); } @@ -437,7 +568,6 @@ const styles = StyleSheet.create({ fontFamily: "Outfit_700Bold", color: COLORS.black, }, - headerSpacer: { width: 40 }, scrollContent: { paddingHorizontal: 20, paddingBottom: 40, @@ -629,4 +759,83 @@ const styles = StyleSheet.create({ gap: 4, }, statusText: { fontSize: 11, fontFamily: "Outfit_600SemiBold" }, + exportBtn: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + }, + // Off-screen but rendered (and collapsable=false) so captureRef can snapshot it. + hiddenReceipt: { + position: "absolute", + left: -9999, + top: 0, + width: 340, + backgroundColor: COLORS.white, + padding: 24, + }, + receiptCard: { + backgroundColor: COLORS.white, + borderRadius: 24, + padding: 24, + borderWidth: 1, + borderColor: "#E0E0E0", + }, + receiptHeader: { + alignItems: "center", + marginBottom: 8, + }, + receiptBrand: { + fontSize: 30, + fontFamily: "Outfit_700Bold", + color: COLORS.primary, + }, + receiptTitle: { + fontSize: 13, + fontFamily: "Outfit_500Medium", + color: "#999", + marginTop: 2, + }, + receiptDivider: { + height: 1, + backgroundColor: "#E8E8E8", + marginVertical: 14, + }, + receiptRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 5, + }, + receiptLabel: { + fontSize: 13, + fontFamily: "Outfit_400Regular", + color: "#666", + }, + receiptValue: { + fontSize: 13, + fontFamily: "Outfit_600SemiBold", + color: COLORS.black, + }, + receiptTotalRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "baseline", + }, + receiptTotalLabel: { + fontSize: 14, + fontFamily: "Outfit_600SemiBold", + color: COLORS.black, + }, + receiptTotalValue: { + fontSize: 20, + fontFamily: "Outfit_700Bold", + color: COLORS.primary, + }, + receiptFooter: { + fontSize: 11, + fontFamily: "Outfit_400Regular", + color: "#bbb", + textAlign: "center", + }, }); diff --git a/mobileapp/package.json b/mobileapp/package.json index 9aed08c..acd20bc 100644 --- a/mobileapp/package.json +++ b/mobileapp/package.json @@ -30,9 +30,9 @@ "expo": "~54.0.32", "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", + "expo-constants": "^18.0.13", "expo-contacts": "~14.2.6", "expo-document-picker": "~57.0.1", - "expo-constants": "^18.0.13", "expo-file-system": "~19.0.22", "expo-font": "~14.0.11", "expo-haptics": "^15.0.8", @@ -61,6 +61,7 @@ "react-native-screens": "~4.16.0", "react-native-svg": "^15.15.1", "react-native-url-polyfill": "^3.0.0", + "react-native-view-shot": "4.0.3", "react-native-web": "^0.21.0", "readable-stream": "^4.7.0", "stream-http": "^3.2.0"