native ui + touch id unlock + portfolio dashboard

This commit is contained in:
2026-08-01 19:07:49 +08:00
parent 42a22918a3
commit bd8ba8e3e5
18 changed files with 675 additions and 194 deletions
+262 -43
View File
@@ -1,31 +1,125 @@
<script lang="ts">
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { electrobun } from '$lib/electrobun.js'
import { navigate } from 'sv-router/generated'
import { onMount } from 'svelte'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardDescription,
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
import { onMount } from 'svelte'
import { Button } from '$lib/components/ui/button/index.js'
import Plus from '@lucide/svelte/icons/plus'
import WalletIcon from '@lucide/svelte/icons/wallet'
import Loader from '$lib/components/loader.svelte'
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
let xrpPrice = $state<string | null>(null)
let xmrPrice = $state<string | null>(null)
let xrpBalances = $state<Record<string, { balance: number; funded: boolean }>>({})
let loadingXrp = $state(true)
let loadingXmr = $state(true)
let feError = $state<string | null>(null)
const usd = (amount: string, price: string | null) =>
price ? parseFloat(amount) * parseFloat(price) : 0
const xrpTotalUsd = $derived.by(() => {
const price = xrpPrice
return xrpWallet.wallets.reduce((sum, w) => {
const b = xrpBalances[w.address]?.balance ?? 0
return sum + (price ? b * parseFloat(price) : 0)
}, 0)
})
const xrpTotalBalance = $derived.by(() =>
xrpWallet.wallets.reduce((sum, w) => {
return sum + (xrpBalances[w.address]?.balance ?? 0)
}, 0),
)
const xmrBalance = $derived(
moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0,
)
const xmrTotalUsd = $derived(
moneroWallet.walletOpen && xmrPrice
? xmrBalance * parseFloat(xmrPrice)
: 0,
)
const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd)
const hasXrp = $derived(xrpWallet.wallets.length > 0)
const hasXmr = $derived(moneroWallet.wallets.length > 0)
const noWallets = $derived(!hasXrp && !hasXmr)
const fmtUsd = (v: number) =>
v.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const fmtBal = (v: number, dp = 2) =>
v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: dp,
})
async function fetchXrp() {
loadingXrp = true
const rpc = electrobun.rpc
if (!rpc) {
loadingXrp = false
return
}
try {
const p = await rpc.request.fetchXrpPrice({})
if (p) xrpPrice = p.usd
} catch { /* ignore */ }
await Promise.allSettled(
xrpWallet.wallets.map(async (w) => {
const res = await rpc.request.xrpGetBalance({
address: w.address,
})
xrpBalances[w.address] = {
balance: parseFloat(res.balance),
funded: res.funded,
}
}),
)
loadingXrp = false
}
async function fetchXmr() {
loadingXmr = true
const rpc = electrobun.rpc
if (!rpc) {
loadingXmr = false
return
}
try {
const p = await rpc.request.fetchMoneroPrice({})
xmrPrice = p?.usd ?? null
} catch { /* ignore */ }
loadingXmr = false
}
onMount(() => {
frontendLog('index.svelte mounted')
frontendLog('dashboard mounted')
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
xrpWallet.init().catch((e) => frontendLogError('xrpWallet.init() failed', e))
xrpWallet.init().then(async () => {
await fetchXrp()
})
fetchXmr()
})
$effect(() => {
if (moneroWallet.accountType === 'monero') {
navigate('/monero')
} else if (xrpWallet.accountType === 'xrp') {
navigate('/xrp')
if (moneroWallet.walletOpen) {
fetchXmr()
}
})
@@ -61,43 +155,168 @@
</div>
{/if}
<div class="mx-auto mt-24 max-w-sm">
<Card>
<CardHeader class="text-center">
<CardTitle>Welcome</CardTitle>
<CardDescription>
Choose an account type to get started
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
<div class="space-y-6">
{#if noWallets}
<div class="flex flex-col items-center justify-center py-16">
<WalletIcon size={48} class="text-muted-foreground/50" />
<p class="mt-4 text-lg font-medium">Welcome to Koins</p>
<p class="mb-6 text-sm text-muted-foreground">Choose an asset to get started</p>
<div class="flex gap-3">
<Button
onclick={async () => {
await moneroWallet.login()
navigate('/monero')
}}
onclick={() => navigate('/xrp')}
variant="outline"
class="w-full">
<img
src="/icons/monero.png"
alt=""
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
Monero
class="gap-2">
<img src="/icons/xrp.png" alt="" class="size-4 rounded-full" />
Set up XRP
</Button>
<Button
onclick={async () => {
await xrpWallet.login()
navigate('/xrp')
}}
onclick={() => navigate('/monero')}
variant="outline"
class="w-full">
<img
src="/icons/xrp.png"
alt=""
class="-ml-1 mr-1.5 inline-block size-5 rounded-full align-middle" />
XRP
class="gap-2">
<img src="/icons/monero.png" alt="" class="size-4 rounded-full" />
Set up Monero
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{:else}
<div class="flex flex-col items-center pt-4 pb-2">
<p class="text-xs text-muted-foreground">Total Portfolio Value</p>
{#if loadingXrp || loadingXmr}
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
{:else}
<p class="mt-1 text-3xl font-semibold tabular-nums">
${fmtUsd(portfolioUsd)}
</p>
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
<button
onclick={() => navigate('/xrp')}
class="text-left transition-transform active:scale-[0.98]">
<Card class="hover:border-foreground/20 transition-colors">
<CardHeader class="pb-3">
<div class="flex items-center gap-2">
<img src="/icons/xrp.png" alt="" class="size-5 rounded-full" />
<CardTitle class="text-base">XRP</CardTitle>
</div>
<CardDescription class="text-xs">
{xrpWallet.wallets.length} wallet{xrpWallet.wallets.length !== 1 ? 's' : ''}
</CardDescription>
</CardHeader>
<CardContent>
{#if loadingXrp}
<div class="h-14 flex items-center"><Loader /></div>
{:else}
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
{#if xrpPrice}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xrpTotalUsd)}
</p>
<p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xrpPrice) )} / XRP
</p>
{/if}
{/if}
</CardContent>
</Card>
</button>
<button
onclick={() => navigate('/monero')}
class="text-left transition-transform active:scale-[0.98]">
<Card class="hover:border-foreground/20 transition-colors">
<CardHeader class="pb-3">
<div class="flex items-center gap-2">
<img src="/icons/monero.png" alt="" class="size-5 rounded-full" />
<CardTitle class="text-base">Monero</CardTitle>
</div>
<CardDescription class="text-xs">
{moneroWallet.walletOpen
? moneroWallet.walletName || 'Wallet open'
: moneroWallet.wallets.length > 0
? `${moneroWallet.wallets.length} wallet${moneroWallet.wallets.length !== 1 ? 's' : ''}`
: 'Not set up'}
</CardDescription>
</CardHeader>
<CardContent>
{#if loadingXmr && !moneroWallet.walletOpen}
<div class="h-14 flex items-center"><Loader /></div>
{:else if moneroWallet.walletOpen}
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
{#if xmrPrice}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xmrTotalUsd)}
</p>
<p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xmrPrice) )} / XMR
</p>
{/if}
{:else if moneroWallet.wallets.length > 0}
<p class="text-sm text-muted-foreground py-3">Open wallet to view balance</p>
{:else}
<Button
onclick={() => navigate('/monero')}
variant="outline"
size="sm"
class="gap-1.5">
<Plus size={14} />
Set up
</Button>
{/if}
</CardContent>
</Card>
</button>
</div>
{#if xrpWallet.wallets.length > 1}
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm">XRP Wallets</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-1">
{#each xrpWallet.wallets as wal}
<button
onclick={() => {
xrpWallet.selectAndUnlockWallet(wal.id)
navigate('/xrp')
}}
class="flex w-full items-center gap-3 rounded-lg p-2 text-left hover:bg-muted transition-colors">
<WalletIcon size={16} class="shrink-0 text-muted-foreground" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{wal.name}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{wal.address}</p>
</div>
{#if xrpBalances[wal.address]?.funded === false}
<span class="text-xs text-muted-foreground">unfunded</span>
{:else if xrpBalances[wal.address]}
<span class="font-mono text-sm tabular-nums">
{fmtBal(xrpBalances[wal.address].balance)}
</span>
{/if}
</button>
{/each}
</div>
</CardContent>
</Card>
{/if}
<div class="flex gap-3">
<Button
onclick={() => navigate('/xrp')}
variant="outline"
class="flex-1 gap-2">
<img src="/icons/xrp.png" alt="" class="size-4 rounded-full" />
XRP Wallet
</Button>
<Button
onclick={() => navigate('/monero')}
variant="outline"
class="flex-1 gap-2">
<img src="/icons/monero.png" alt="" class="size-4 rounded-full" />
Monero Wallet
</Button>
</div>
{/if}
</div>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
import { route } from 'sv-router/generated'
import { session } from '$lib/states/session.svelte.js'
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import House from '@lucide/svelte/icons/house'
import SettingsIcon from '@lucide/svelte/icons/settings'
import Fingerprint from '@lucide/svelte/icons/fingerprint'
import LockIcon from '@lucide/svelte/icons/lock'
let { children } = $props()
const title = $derived.by(() => {
const p = route.pathname
if (p === '/') return 'Koins'
if (p === '/settings') return 'Settings'
if (p.startsWith('/xrp/tx/')) return 'Transaction'
if (p === '/xrp/send') return 'Send'
if (p.startsWith('/xrp')) return 'XRP'
if (p.startsWith('/monero/tx/')) return 'Transaction'
if (p === '/monero/send') return 'Send'
if (p === '/monero/settings') return 'Monero Settings'
if (p.startsWith('/monero')) return 'Monero'
return 'Koins'
})
const handleUnlock = async () => {
const ok = await session.unlock()
if (!ok) return
if (!xrpWallet.ready) await xrpWallet.init()
if (!moneroWallet.ready) await moneroWallet.init()
await Promise.allSettled([
xrpWallet.autoUnlock(),
moneroWallet.autoUnlock(),
])
}
const itemClass = (active: boolean) =>
`flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors ${
active
? 'bg-sidebar-accent font-medium text-sidebar-accent-foreground'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground'
}`
</script>
{#if !session.unlocked}
<div
class="flex h-screen w-screen flex-col items-center justify-center bg-background gap-6">
<div class="flex flex-col items-center gap-4">
<div
class="flex size-16 items-center justify-center rounded-full bg-muted">
<Fingerprint size={32} class="text-muted-foreground" />
</div>
<div class="text-center">
<p class="text-lg font-semibold">Koins</p>
<p class="text-sm text-muted-foreground">
Use Touch ID to unlock
</p>
</div>
</div>
{#if session.biometricAvailable}
<Button
onclick={handleUnlock}
size="lg"
class="gap-2"
disabled={session.unlocking}>
<Fingerprint size={18} />
{session.unlocking ? 'Unlocking...' : 'Unlock'}
</Button>
{:else}
<p class="text-sm text-muted-foreground max-w-xs text-center">
Biometric authentication isn't available on this device.
</p>
{/if}
{#if session.error}
<p class="text-sm text-destructive">{session.error}</p>
{/if}
</div>
{:else}
<div class="flex h-screen w-screen overflow-hidden">
<aside class="flex w-60 shrink-0 flex-col bg-sidebar text-sidebar-foreground">
<div class="px-3 pt-9">
<p class="px-2 pb-4 text-sm font-semibold">Koins</p>
</div>
<div class="flex-1 overflow-y-auto px-3">
<nav class="space-y-0.5">
<a href="/" class={itemClass(route.pathname === '/')}>
<House size={16} class="shrink-0" />
<span>Home</span>
</a>
</nav>
<p
class="mb-1 mt-4 px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Wallets
</p>
<nav class="space-y-0.5">
<a href="/xrp" class={itemClass(route.pathname.startsWith('/xrp'))}>
<img
src="/icons/xrp.png"
alt=""
class="size-4 shrink-0 rounded-full" />
<span>XRP</span>
</a>
<a
href="/monero"
class={itemClass(route.pathname.startsWith('/monero'))}>
<img
src="/icons/monero.png"
alt=""
class="size-4 shrink-0 rounded-full" />
<span>Monero</span>
</a>
</nav>
</div>
<div class="border-t border-sidebar-border px-3 pb-4 pt-2 space-y-0.5">
<a
href="/settings"
class={itemClass(route.pathname === '/settings')}>
<SettingsIcon size={16} class="shrink-0" />
<span>Settings</span>
</a>
<button
onclick={() => session.lock()}
class="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground transition-colors">
<LockIcon size={16} class="shrink-0" />
<span>Lock</span>
</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
<header
class="relative flex h-12 shrink-0 items-center border-b border-border px-3">
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="text-sm font-medium">{title}</span>
</div>
</header>
<main class="min-w-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full max-w-2xl px-8 py-8">
{@render children?.()}
</div>
</main>
</div>
</div>
{/if}
+15 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { session } from '$lib/states/session.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Textarea } from '$lib/components/ui/textarea/index.js'
import { Input } from '$lib/components/ui/input/index.js'
@@ -47,7 +48,7 @@
})
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
{#if !w.ready}
<p class="text-center text-muted-foreground text-sm mt-8">
@@ -59,9 +60,6 @@
to get started
</p>
{:else if w.accountType === 'monero'}
<a href="/" class="text-xs text-muted-foreground underline mb-1 inline-block">
← Back to home
</a>
{#if w.downloading}
<Card>
<CardContent>
@@ -134,7 +132,19 @@
</div>
{#if moneroSelectedWallet}
<div class="flex flex-col gap-2">
{#if w.biometricAvailable}
{#if session.unlocked}
<Button
onclick={() =>
w.autoUnlock(
moneroSelectedWallet,
)}
disabled={w.loading}>
{#if w.loading}
<Loader />
{/if}
Open
</Button>
{:else if w.biometricAvailable}
<Button
onclick={async () => {
const ok = await w.biometricAuth()
+1 -9
View File
@@ -92,15 +92,7 @@
}
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() => navigate('/monero')}
class="mb-4">
&larr; Back
</Button>
<div class="mx-auto max-w-xl">
<Card>
<CardHeader>
<CardTitle>Send XMR</CardTitle>
@@ -21,7 +21,7 @@
})
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
<Card>
<CardHeader>
@@ -82,14 +82,7 @@
</p>
{/if}
<div class="pt-2 border-t border-border">
<Button
variant="outline"
size="sm"
onclick={() => navigate('/monero')}>
Back
</Button>
</div>
</div>
</CardContent>
</Card>
@@ -35,15 +35,7 @@
})
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() => navigate('/monero')}
class="mb-4">
&larr; Back
</Button>
<div class="mx-auto max-w-xl">
<Card>
<CardHeader>
<CardTitle>Transaction Details</CardTitle>
@@ -146,7 +146,7 @@
})
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
<Card>
{@const a = chunkAddress(w.address)}
+2 -16
View File
@@ -33,23 +33,9 @@
}
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() =>
w.currentWalletId
? navigate('/xrp/wallet/:id', {
params: { id: w.currentWalletId },
})
: navigate('/xrp')}>
<ArrowLeft size={16} />
Back
</Button>
</div>
{#if w.wallets.length > 0}
{#if w.wallets.length > 0}
<Card>
<CardHeader>
<CardTitle>Wallets</CardTitle>
+5 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { session } from '$lib/states/session.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import { Textarea } from '$lib/components/ui/textarea/index.js'
@@ -63,7 +64,7 @@
}
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
{#if !w.ready}
<p class="text-center text-muted-foreground text-sm mt-8">
@@ -75,11 +76,6 @@
to get started
</p>
{:else}
<a
href="/"
class="text-xs text-muted-foreground underline mb-1 inline-block">
← Back to home
</a>
{#if newSeed}
<Card>
<CardHeader>
@@ -177,7 +173,9 @@
{#each w.wallets as wal}
<button
onclick={async () => {
if (wal.hasPassword) {
if (session.unlocked) {
await w.autoUnlock(wal.id)
} else if (wal.hasPassword) {
await w.selectWallet(wal.id)
} else {
await w.selectAndUnlockWallet(wal.id)
+1 -12
View File
@@ -59,18 +59,7 @@
}
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() =>
navigate('/xrp/wallet/:id', {
params: { id: w.currentWalletId ?? '' },
})}
class="mb-4">
&larr; Back
</Button>
<div class="mx-auto max-w-xl">
<Card>
<CardHeader>
<CardTitle>Send XRP</CardTitle>
+1 -12
View File
@@ -35,18 +35,7 @@
})
</script>
<div class="mx-auto mt-16 max-w-md">
<Button
variant="outline"
size="sm"
onclick={() =>
navigate('/xrp/wallet/:id', {
params: { id: xrpWallet.currentWalletId ?? '' },
})}
class="mb-4">
&larr; Back
</Button>
<div class="mx-auto max-w-xl">
<Card>
<CardHeader>
<CardTitle>Transaction Details</CardTitle>
@@ -55,7 +55,7 @@
})
</script>
<div class="mx-auto mt-16 max-w-md">
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
<Card>
<CardHeader>