From 12f8be45532c3d6b1fcda99ee5d9033237462939 Mon Sep 17 00:00:00 2001
From: Divine-designs <208155579+Divine-designs@users.noreply.github.com>
Date: Sun, 30 Aug 2026 16:03:34 +0100
Subject: [PATCH] feat: mobile app: add transaction receipt export and PNG
share generator (#694)
---
.../__tests__/BatchReceiptExport.test.tsx | 65 +++++
mobileapp/app/transaction/batch/[id].tsx | 251 ++++++++++++++++--
mobileapp/package.json | 3 +-
3 files changed, 297 insertions(+), 22 deletions(-)
create mode 100644 mobileapp/__tests__/BatchReceiptExport.test.tsx
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"