Skip to content

Commit 73f0933

Browse files
NateIsernclaude
andcommitted
Full i18n, MapLibre Places, language picker, chain stats, audio, SPV audit
This is a large release covering most of the remaining UX, service, and tooling work: - **i18n audit**: every user-facing string is now routed through `t()` across 28 screens and 7 shared components. The catalog grew to 468 keys per language in both English and Spanish with `{placeholder}` interpolation support. A new language picker subscreen (`app/language.tsx`) lists 20 languages with search, persists via kv-store, and re-renders the entire tree on change. Accessible from Welcome and Settings. - **Places map** (`app/map.tsx`): native-only screen showing places that accept FairCoin, with search, bottom-sheet list, camera animation, branded category markers, 3D pitch, and user-location FAB. Uses `@maplibre/maplibre-react-native` with Carto voyager / dark-matter tiles — fully free, no tokens, no usage limits. `app/map.web.tsx` renders the "mobile only" fallback on web/electron. - **Chain stats** (`app/chain.tsx`): new Network Status subscreen with sync pill, block height, peer count (tappable), network type, and last block time. Wired into Settings → Network. - **Audio + notifications**: `src/services/sounds.ts` plays `sent.mp3`, `received.mp3`, and `unlocked.mp3` via `expo-audio`. `src/services/tx-notifier.ts` subscribes to the wallet store to fire the received sound + a local notification on new incoming txs (foreground only — background polling is stubbed pending wallet store refactor). Hooks into lock / send / layout boot. - **Centralized branding** (`src/core/branding.ts`): single source of truth for `COIN_NAME`, `COIN_TICKER`, `COIN_SYMBOL`, `APP_NAME`, `UNITS_PER_COIN`, `SMALLEST_UNIT_NAME` (m⊜), and explorer URL helpers `explorerTxUrl(txid)` / `explorerAddressUrl(address)`. Every magic literal (`100_000_000n`, `⊜`, `fairco.in` URLs, `FairCoin`, `FAIRWallet`) now sources from here. - **Amount formatting**: new reusable `<AmountInput>` and `<AmountText>` components built on `react-number-format`'s `numericFormatter` helper (RN-compatible, no DOM event hacks). Auto-formats thousands separators and 8-decimal clamping while typing. Used across send, balance display, transaction item, transaction detail, and coin control. - **Function renames**: `formatSats` → `formatUnits`, `parseFairToSats` → `parseFairToUnits`, `<AmountText sats>` → `value`. User-facing "satoshis"/"sats" strings replaced with `m⊜`. Internal computation variables kept as-is. - **File-based backup**: settings backup export/import now uses real files via `expo-file-system` `File`/`Paths` API + `expo-document-picker` + `expo-sharing`. No clipboard, no `/legacy`. - **Revolut parallax home**: hero image with reanimated translate and overscroll zoom, `LinearGradient` fade, top bar overlaid with white text + drop shadow for readability. - **Material 3 nav rail**: responsive tab layout switches between bottom bar (< 600px) and 80px-wide nav rail with 56×32 pill active indicator (≥ 600px). Full-width content at every viewport. - **Screen headers**: `<ScreenHeader>` gained an `onBack` convenience prop. Every modal-presented screen (wallets, masternode, coin-control, export-key, transaction, contacts, chain, language, map) has a proper header with a back button and no Stack/nav header collision. - **PIN screens**: lock and onboarding/pin-setup now show the app icon logo (88px rounded) instead of the `⊜` glyph. `<PinPad>` gained web/electron keyboard support (digits 0-9, Backspace, Delete). - **Buy button**: new quick action on the home screen opens `buy.fairco.in` in the in-app browser via `expo-web-browser`, parameterized with the user's receive address, language, and country. - **Transaction sent prompt**: after a successful send, a Bloom `Prompt.Outer` shows the explorer link with Copy / Share (via `expo-sharing`) / Done actions. - **Custom 404**: `app/+not-found.tsx` renders a branded page-not-found screen with a back button and a "back to wallet" CTA. - **Settings SettingsListGroup**: the whole settings screen migrated to Bloom's `SettingsListGroup` + `SettingsListItem`. Fixed duplicated horizontal padding. Added language row + chain stats row + made connected-peers row tappable. - **Bloom bumps**: `@oxyhq/bloom` → `0.1.36` — includes duplicate-handle fix in `Dialog`, native `name` prop on `Avatar` (deterministic initial + color), and `setColorScheme` web guard. - **SPV audit + tests** (see `SPV_AUDIT.md`): 23 modules audited, 222 unit tests passing across 9 test files (encoding, address, hd-wallet, format-amount, transaction, quark-hash, network, bloom-filter, messages). Found and fixed a **critical Quark hash bug** — the pure-TS BLAKE-512 collapsed all 80-byte inputs to the same digest. Replaced with `@noble/hashes/blake1`'s `blake512`. Documented 7 remaining bugs and 15 missing SPV features for follow-up, including the most urgent: `wallet-store.onTransaction` is empty so received funds never update the wallet — **blocker for wallet usability**. Also missing: header PoW validation, reorg detection and UTXO rewind, BIP44 cursor persistence. See `SPV_AUDIT.md` for the full checklist and priorities. - **Tooling**: `bun test` as the test runner (no jest/vitest), `@types/bun` for test types, `bun-types` in `tsconfig.json`. `image-types.d.ts` ambient declarations for `.jpg`/`.png` imports. Electron menus (File / Edit / View / Wallet / Help with cross-platform accelerators). - **Predictive back gesture** enabled in `app.json` for Android 13+. - **Electron fixes**: `fairwallet://` registered as a privileged scheme, `ElectronSocketProvider` rewritten to honour the Promise-based IPC contract, custom protocol handler fixed for absolute asset paths, `electron` installed as a dev dep, application menus. - **QR scanner**: redesigned with Revolut-style L-corner indicators, torch toggle, animated scan line (reanimated), branded chrome. - **Contacts**: Google Contacts-inspired redesign with pill search, full-screen edit modal, underlined Material-style inputs, `ContactAvatar` wrapper over Bloom's `Avatar` `name` prop (deterministic initial + color). Long-press menu now uses a Bloom `Prompt` instead of RN `Alert.alert`. **Zero `Alert.alert` references remain in the codebase.** Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8779103 commit 73f0933

72 files changed

Lines changed: 7142 additions & 671 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

SPV_AUDIT.md

Lines changed: 227 additions & 0 deletions
Large diffs are not rendered by default.

app.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,20 @@
5555
"backgroundColor": "#1b1e09"
5656
}
5757
}],
58-
["expo-camera", { "cameraPermission": "FAIRWallet needs camera access to scan QR codes." }]
58+
["expo-camera", { "cameraPermission": "FAIRWallet needs camera access to scan QR codes." }],
59+
["expo-location", {
60+
"locationWhenInUsePermission": "FAIRWallet uses your location to show nearby places that accept FairCoin."
61+
}],
62+
"@maplibre/maplibre-react-native",
63+
["expo-notifications", {
64+
"color": "#1b1e09",
65+
"sounds": [
66+
"./assets/sounds/sent.mp3",
67+
"./assets/sounds/received.mp3",
68+
"./assets/sounds/unlocked.mp3"
69+
],
70+
"enableBackgroundRemoteNotifications": false
71+
}]
5972
],
6073
"experiments": {
6174
"typedRoutes": true

app/(tabs)/_layout.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ThemeProvider } from "@react-navigation/native";
77
import { NativeTabs } from "expo-router/unstable-native-tabs";
88
import { useMemo } from "react";
99
import { useTheme } from "@oxyhq/bloom/theme";
10+
import { t } from "../../src/i18n";
1011

1112
export default function TabLayout() {
1213
const theme = useTheme();
@@ -54,7 +55,7 @@ export default function TabLayout() {
5455
sf={{ default: "creditcard", selected: "creditcard.fill" }}
5556
md="wallet"
5657
/>
57-
<NativeTabs.Trigger.Label>Wallet</NativeTabs.Trigger.Label>
58+
<NativeTabs.Trigger.Label>{t("wallet.title")}</NativeTabs.Trigger.Label>
5859
</NativeTabs.Trigger>
5960

6061
<NativeTabs.Trigger
@@ -65,7 +66,7 @@ export default function TabLayout() {
6566
sf={{ default: "arrow.up.circle", selected: "arrow.up.circle.fill" }}
6667
md="arrow_upward"
6768
/>
68-
<NativeTabs.Trigger.Label>Send</NativeTabs.Trigger.Label>
69+
<NativeTabs.Trigger.Label>{t("wallet.send")}</NativeTabs.Trigger.Label>
6970
</NativeTabs.Trigger>
7071

7172
<NativeTabs.Trigger
@@ -76,7 +77,7 @@ export default function TabLayout() {
7677
sf={{ default: "arrow.down.circle", selected: "arrow.down.circle.fill" }}
7778
md="arrow_downward"
7879
/>
79-
<NativeTabs.Trigger.Label>Receive</NativeTabs.Trigger.Label>
80+
<NativeTabs.Trigger.Label>{t("wallet.receive")}</NativeTabs.Trigger.Label>
8081
</NativeTabs.Trigger>
8182

8283
<NativeTabs.Trigger
@@ -87,7 +88,7 @@ export default function TabLayout() {
8788
sf={{ default: "gearshape", selected: "gearshape.fill" }}
8889
md="settings"
8990
/>
90-
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
91+
<NativeTabs.Trigger.Label>{t("wallet.settings")}</NativeTabs.Trigger.Label>
9192
</NativeTabs.Trigger>
9293
</NativeTabs>
9394
</ThemeProvider>

app/(tabs)/_layout.web.tsx

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ import { t } from "../../src/i18n";
4040
import { useTheme } from "@oxyhq/bloom/theme";
4141

4242
const MEDIUM_BREAKPOINT = 600;
43-
const ULTRAWIDE_BREAKPOINT = 1400;
4443
const RAIL_WIDTH = 80;
45-
const CONTENT_MAX_WIDTH = 1200;
4644
const RAIL_INDICATOR_WIDTH = 56;
4745
const RAIL_INDICATOR_HEIGHT = 32;
4846
const RAIL_ICON_SIZE = 24;
@@ -71,7 +69,6 @@ export default function TabLayout() {
7169
const theme = useTheme();
7270
const { width } = useWindowDimensions();
7371
const showRail = width >= MEDIUM_BREAKPOINT;
74-
const isUltrawide = width >= ULTRAWIDE_BREAKPOINT;
7572
const pathname = usePathname();
7673

7774
const tabsStyle: StyleProp<ViewStyle> = {
@@ -80,7 +77,6 @@ export default function TabLayout() {
8077
minHeight: 0,
8178
};
8279

83-
const slotContainerStyle: StyleProp<ViewStyle> = styles.slotContainer;
8480

8581
const tabs: readonly TabDef[] = [
8682
{ name: "index", href: "/", icon: "wallet", label: t("wallet.title") },
@@ -109,6 +105,12 @@ export default function TabLayout() {
109105
const color = active ? theme.colors.primary : theme.colors.textSecondary;
110106

111107
if (showRail) {
108+
const indicatorBg = active
109+
? theme.colors.primarySubtle
110+
: "transparent";
111+
const iconColor = active
112+
? theme.colors.primarySubtleForeground
113+
: theme.colors.textSecondary;
112114
return (
113115
<TabTrigger
114116
key={tab.name}
@@ -120,15 +122,13 @@ export default function TabLayout() {
120122
<View
121123
style={[
122124
styles.railIndicator,
123-
active && {
124-
backgroundColor: `${theme.colors.primary}1A`,
125-
},
125+
{ backgroundColor: indicatorBg },
126126
]}
127127
>
128128
<MaterialCommunityIcons
129129
name={tab.icon}
130130
size={RAIL_ICON_SIZE}
131-
color={color}
131+
color={iconColor}
132132
/>
133133
</View>
134134
</View>
@@ -170,15 +170,8 @@ export default function TabLayout() {
170170
>
171171
{tabs.map(renderTrigger)}
172172
</TabList>
173-
<View style={slotContainerStyle}>
174-
<View
175-
style={[
176-
styles.slotInner,
177-
isUltrawide && styles.slotInnerUltrawide,
178-
]}
179-
>
180-
<TabSlot />
181-
</View>
173+
<View style={styles.slotContainer}>
174+
<TabSlot />
182175
</View>
183176
</>
184177
) : (
@@ -249,30 +242,13 @@ const styles = StyleSheet.create({
249242

250243
// Wrapper around <TabSlot /> that owns the flex sizing. Without this the
251244
// default TabSlot style (`flexShrink: 0`) prevents react-native-web's
252-
// `<ScrollView>` from receiving a bounded height. We also center children
253-
// horizontally so the inner cap-width column on ultrawide displays sits in
254-
// the middle of the available space (not vertically centered, which is what
255-
// `alignSelf: 'center'` would do inside a row-direction parent).
245+
// `<ScrollView>` from receiving a bounded height. Always full width and
246+
// top-aligned so screens fill the available area on every viewport.
256247
slotContainer: {
257248
flex: 1,
258249
minHeight: 0,
259250
minWidth: 0,
260251
alignSelf: "stretch",
261-
alignItems: "center",
262-
},
263-
// Inner wrapper that holds the actual <TabSlot />. Always full width on
264-
// smaller screens, capped to CONTENT_MAX_WIDTH on ultrawide so cards and
265-
// text don't stretch across the viewport. Content is top-aligned because
266-
// the wrapper itself is a flex column with the default `justifyContent:
267-
// 'flex-start'`.
268-
slotInner: {
269-
flex: 1,
270-
width: "100%",
271-
minHeight: 0,
272-
minWidth: 0,
273-
},
274-
slotInnerUltrawide: {
275-
maxWidth: CONTENT_MAX_WIDTH,
276252
},
277253

278254
// ── Bottom tab bar (< 600px) ──────────────────────────────────────────────

app/(tabs)/index.tsx

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ import {
4242
type PriceData,
4343
} from "../../src/services/price";
4444
import { useTheme } from "@oxyhq/bloom/theme";
45-
import { formatFair } from "../../src/core/format-amount";
45+
import { BUY_BASE_URL } from "../../src/core/branding";
46+
import { t } from "../../src/i18n";
4647

4748
// ---------------------------------------------------------------------------
4849
// Constants
@@ -92,9 +93,7 @@ export default function HomeScreen() {
9293
language,
9394
country,
9495
});
95-
await WebBrowser.openBrowserAsync(
96-
`https://buy.fairco.in/?${params.toString()}`,
97-
);
96+
await WebBrowser.openBrowserAsync(`${BUY_BASE_URL}/?${params.toString()}`);
9897
}, [receiveAddress]);
9998

10099
const [price, setPrice] = useState<PriceData | null>(getCachedPrice);
@@ -113,9 +112,16 @@ export default function HomeScreen() {
113112
const recentTransactions = transactions.slice(0, 10);
114113

115114
const syncState = useMemo(() => {
116-
if (connectedPeers === 0) return { dot: "bg-red-400", label: "Offline" };
117-
if (isSyncing) return { dot: "bg-yellow-400", label: `Syncing ${Math.round(syncProgress)}%` };
118-
return { dot: "bg-primary", label: "Synced" };
115+
if (connectedPeers === 0) {
116+
return { dot: "bg-red-400", label: t("wallet.sync.offline") };
117+
}
118+
if (isSyncing) {
119+
return {
120+
dot: "bg-yellow-400",
121+
label: t("wallet.sync.syncing", { progress: Math.round(syncProgress) }),
122+
};
123+
}
124+
return { dot: "bg-primary", label: t("wallet.sync.synced") };
119125
}, [connectedPeers, isSyncing, syncProgress]);
120126

121127
// ---- Parallax: track scroll, derive translate + scale on the UI thread ----
@@ -188,7 +194,7 @@ export default function HomeScreen() {
188194
{/* ---- Balance (sits in the gradient fade zone) ---- */}
189195
<View className="items-center pt-8 pb-6 px-6">
190196
<BalanceDisplay
191-
sats={balance}
197+
value={balance}
192198
priceUsd={price?.usd}
193199
change24h={price?.change24h}
194200
size="lg"
@@ -201,27 +207,32 @@ export default function HomeScreen() {
201207
<View className="flex-row justify-evenly px-4 pb-6">
202208
<ActionButton
203209
icon="arrow-up-bold"
204-
label="Send"
210+
label={t("wallet.send")}
205211
onPress={() => router.push("/(tabs)/send")}
206212
/>
207213
<ActionButton
208214
icon="arrow-down-bold"
209-
label="Receive"
215+
label={t("wallet.receive")}
210216
onPress={() => router.push("/(tabs)/receive")}
211217
/>
212218
<ActionButton
213219
icon="credit-card-plus"
214-
label="Buy"
220+
label={t("wallet.buy")}
215221
onPress={handleBuy}
216222
/>
223+
<ActionButton
224+
icon="map-marker"
225+
label={t("wallet.places")}
226+
onPress={() => router.push("/map")}
227+
/>
217228
<ActionButton
218229
icon="account-group"
219-
label="Contacts"
230+
label={t("wallet.contacts")}
220231
onPress={() => router.push("/contacts")}
221232
/>
222233
<ActionButton
223234
icon="server-network"
224-
label="Nodes"
235+
label={t("wallet.nodes")}
225236
onPress={() => router.push("/masternode")}
226237
/>
227238
</View>
@@ -237,11 +248,14 @@ export default function HomeScreen() {
237248
</View>
238249
<View className="flex-row justify-between mt-1">
239250
<Text className="text-muted-foreground text-[10px]">
240-
{connectedPeers} {connectedPeers === 1 ? "peer" : "peers"}
251+
{connectedPeers}{" "}
252+
{connectedPeers === 1
253+
? t("wallet.peer.one")
254+
: t("wallet.peer.other")}
241255
</Text>
242256
{chainHeight > 0 ? (
243257
<Text className="text-muted-foreground text-[10px]">
244-
Block {chainHeight.toLocaleString()}
258+
{t("wallet.block", { height: chainHeight.toLocaleString() })}
245259
</Text>
246260
) : null}
247261
</View>
@@ -253,19 +267,27 @@ export default function HomeScreen() {
253267
<Divider className="mb-5" />
254268

255269
<View className="flex-row items-center justify-between mb-3">
256-
<Text className="text-foreground text-lg font-semibold">Activity</Text>
270+
<Text className="text-foreground text-lg font-semibold">
271+
{t("wallet.activity")}
272+
</Text>
257273
{transactions.length > 0 ? (
258274
<Text className="text-muted-foreground text-xs">
259-
{transactions.length} transaction{transactions.length !== 1 ? "s" : ""}
275+
{transactions.length === 1
276+
? t("wallet.transactionCount.one", {
277+
count: transactions.length,
278+
})
279+
: t("wallet.transactionCount.other", {
280+
count: transactions.length,
281+
})}
260282
</Text>
261283
) : null}
262284
</View>
263285

264286
{recentTransactions.length === 0 ? (
265287
<EmptyState
266288
icon="swap-vertical"
267-
title="No activity yet"
268-
subtitle="Your transactions will appear here"
289+
title={t("wallet.activity.empty.title")}
290+
subtitle={t("wallet.activity.empty.subtitle")}
269291
/>
270292
) : (
271293
<View className="bg-surface rounded-2xl overflow-hidden">
@@ -274,9 +296,7 @@ export default function HomeScreen() {
274296
<TransactionItem
275297
txid={tx.txid}
276298
type={tx.type}
277-
amount={formatFair(
278-
tx.amount < 0n ? -tx.amount : tx.amount,
279-
)}
299+
value={tx.amount}
280300
address={tx.address}
281301
timestamp={tx.timestamp}
282302
confirmations={tx.confirmations}
@@ -309,27 +329,29 @@ export default function HomeScreen() {
309329
className="flex-row items-center active:opacity-60"
310330
onPress={() => router.push("/wallets")}
311331
accessibilityRole="button"
312-
accessibilityLabel="Switch wallet"
332+
accessibilityLabel={t("wallet.switchAccessibility")}
313333
>
314334
<Text
315335
className="text-white text-base font-semibold"
316336
style={TOP_BAR_TEXT_SHADOW}
317337
>
318-
{activeWalletName || "FAIRWallet"}
338+
{activeWalletName || t("wallet.defaultName")}
319339
</Text>
320340
<MaterialCommunityIcons name="chevron-down" size={18} color="#ffffff" />
321341
</Pressable>
322342

323343
{/* Right: network badge + sync status (tappable → peers) */}
324344
<View className="flex-row items-center gap-2">
325345
{network === "testnet" ? (
326-
<Badge text="TESTNET" variant="warning" size="sm" />
346+
<Badge text={t("wallet.badge.testnet")} variant="warning" size="sm" />
327347
) : null}
328348
<Pressable
329349
className="flex-row items-center active:opacity-60"
330350
onPress={() => router.push("/peers")}
331351
accessibilityRole="button"
332-
accessibilityLabel={`Sync status: ${syncState.label}`}
352+
accessibilityLabel={t("wallet.syncAccessibility", {
353+
label: syncState.label,
354+
})}
333355
>
334356
<View className={`w-1.5 h-1.5 rounded-full ${syncState.dot} mr-1`} />
335357
<Text className="text-white text-[11px]" style={TOP_BAR_TEXT_SHADOW}>

0 commit comments

Comments
 (0)