Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"backgroundColor": "#000000"
}
}
]
],
"expo-barcode-scanner"
],
"experiments": {
"typedRoutes": true,
Expand Down
123 changes: 123 additions & 0 deletions apps/mobile/app/(tabs)/pay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React, { useEffect, useState } from "react";
import { View, Text, TextInput, Button, Alert } from "react-native";
import { CameraView, useCameraPermissions } from "expo-camera";
import { router } from "expo-router";
import { apiPost } from "../../src/api";

export default function PayScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const [toAddress, setToAddress] = useState<string>("");
const [amount, setAmount] = useState<string>("");

// TODO later: replace with real login token
const token: string | undefined = undefined;

useEffect(() => {
if (!permission) requestPermission();
}, [permission]);

function parseQR(data: string): string {
try {
const obj = JSON.parse(data);
if (obj?.address && typeof obj.address === "string") return obj.address;
} catch {}
return data.trim();
}

async function sendPayment() {
if (!toAddress) return Alert.alert("Scan a QR first");
const n = Number(amount);
if (!amount || Number.isNaN(n) || n <= 0) return Alert.alert("Invalid amount");

try {
const res = await apiPost(
"/tx/send",
{ to_address: toAddress, amount_xrp: n },
token
);

router.push({
pathname: "/receipt",
params: { status: "success", to: toAddress, amount: String(n), txHash: res?.tx_hash ?? "" },
});
} catch (e: any) {
router.push({
pathname: "/receipt",
params: { status: "fail", to: toAddress, amount: String(n), error: e?.message ?? String(e) },
});
}
}

if (!permission) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Requesting camera permission...</Text>
</View>
);
}

if (!permission.granted) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", padding: 16 }}>
<Text style={{ marginBottom: 12 }}>Camera permission denied.</Text>
<Button title="Grant Permission" onPress={requestPermission} />
</View>
);
}

return (
<View style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 20, marginBottom: 12 }}>Pay (Scan QR)</Text>

{!toAddress ? (
<View style={{ flex: 1 }}>
<View style={{ flex: 1, borderRadius: 12, overflow: "hidden" }}>
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={
scanned
? undefined
: ({ data }) => {
const addr = parseQR(data);
setToAddress(addr);
setScanned(true);
}
}
/>
</View>

<View style={{ height: 12 }} />
<Button title="Scan Again" onPress={() => setScanned(false)} />
</View>
) : (
<View style={{ flex: 1 }}>
<Text style={{ marginBottom: 8 }}>To:</Text>
<Text selectable style={{ marginBottom: 16, fontWeight: "600" }}>
{toAddress}
</Text>

<Text style={{ marginBottom: 8 }}>Amount (XRP)</Text>
<TextInput
value={amount}
onChangeText={setAmount}
keyboardType="decimal-pad"
placeholder="e.g. 1.5"
style={{ borderWidth: 1, borderColor: "#ccc", borderRadius: 10, padding: 12, marginBottom: 16 }}
/>

<Button title="Send Payment" onPress={sendPayment} />
<View style={{ height: 12 }} />
<Button
title="Change receiver"
onPress={() => {
setToAddress("");
setScanned(false);
}}
/>
</View>
)}
</View>
);
}
38 changes: 38 additions & 0 deletions apps/mobile/app/(tabs)/receipt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { View, Text, Button } from "react-native";
import { useLocalSearchParams, router } from "expo-router";

export default function ReceiptScreen() {
const params = useLocalSearchParams<{
status?: string;
to?: string;
amount?: string;
txHash?: string;
error?: string;
}>();

const ok = params.status === "success";

return (
<View style={{ flex: 1, padding: 16, justifyContent: "center" }}>
<Text style={{ fontSize: 22, marginBottom: 12 }}>
{ok ? "✅ Payment Sent" : "❌ Payment Failed"}
</Text>

<Text style={{ marginBottom: 6 }}>To: {params.to}</Text>
<Text style={{ marginBottom: 12 }}>Amount: {params.amount} XRP</Text>

{ok ? (
<Text selectable style={{ marginBottom: 12 }}>
Tx Hash: {params.txHash || "(not returned)"}
</Text>
) : (
<Text selectable style={{ marginBottom: 12, color: "red" }}>
Error: {params.error || "(unknown)"}
</Text>
)}

<Button title="Back to Wallet" onPress={() => router.push("/wallet")} />
</View>
);
}
32 changes: 32 additions & 0 deletions apps/mobile/app/(tabs)/receive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from "react";
import { View, Text } from "react-native";
import QRCode from "react-native-qrcode-svg";

export default function ReceiveScreen() {
const address = "rPLACEHOLDER"; // temporary demo address

const payload = JSON.stringify({ address });

return (
<View
style={{
flex: 1,
padding: 16,
justifyContent: "center",
alignItems: "center",
}}
>
<Text style={{ fontSize: 20, marginBottom: 16 }}>Receive</Text>

<QRCode value={payload} size={220} />

<Text style={{ marginTop: 16 }} selectable>
{address}
</Text>

<Text style={{ marginTop: 8, color: "#666" }}>
QR contains {"{ address }"}
</Text>
</View>
);
}
70 changes: 70 additions & 0 deletions apps/mobile/app/(tabs)/wallet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useEffect, useState } from "react";
import { View, Text, Button, ActivityIndicator } from "react-native";
import { apiGet, apiPost } from "../../src/api";
import { router } from "expo-router";


function shortAddr(addr?: string) {
if (!addr) return "";
return addr.length > 12 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr;
}

export default function WalletScreen() {
const [address, setAddress] = useState<string>("");
const [balance, setBalance] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>("");

// TODO later: replace with real Firebase token
const token: string | undefined = undefined;

async function load() {
setLoading(true);
setError("");
try {
const init = await apiPost("/wallet/init", {}, token);
setAddress(init.address);
const bal = await apiGet("/wallet/balance", token);
setBalance(bal.balance_xrp);
} catch (e: any) {
setError(e.message || String(e));
} finally {
setLoading(false);
}
}

useEffect(() => {
load();
}, []);

return (
<View style={{ flex: 1, padding: 16, justifyContent: "center" }}>
<Text style={{ fontSize: 22, marginBottom: 16 }}>Wallet</Text>

{loading ? <ActivityIndicator /> : null}

{!loading ? (
<>
<Text style={{ marginBottom: 8 }}>
Address: <Text selectable>{shortAddr(address)}</Text>
</Text>
<Text style={{ marginBottom: 16 }}>
Balance: <Text style={{ fontWeight: "bold" }}>{balance || "-"}</Text> XRP
</Text>

{error ? (
<Text style={{ color: "red", marginBottom: 12 }} selectable>
{error}
</Text>
) : null}

<Button title="Refresh" onPress={load} />
<View style={{ height: 12 }} />
<Button title="Receive (QR)" onPress={() => router.push("/receive")} />
<View style={{ height: 12 }} />
<Button title="Pay (Scan QR)" onPress={() => router.push("/pay")} />
</>
) : null}
</View>
);
}
Loading