Pin & Penny expense tracker (web + expo-app)
This commit is contained in:
279
expo-app/App.js
Normal file
279
expo-app/App.js
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, SafeAreaView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useFonts, PetitFormalScript_400Regular } from "@expo-google-fonts/petit-formal-script";
|
||||
import { ShadowsIntoLightTwo_400Regular } from "@expo-google-fonts/shadows-into-light-two";
|
||||
import { StyleScript_400Regular } from "@expo-google-fonts/style-script";
|
||||
import { Waterfall_400Regular } from "@expo-google-fonts/waterfall";
|
||||
import ExpenseForm from "./src/components/ExpenseForm";
|
||||
import ExpenseGroups from "./src/components/ExpenseGroups";
|
||||
import RepeatTemplates from "./src/components/RepeatTemplates";
|
||||
import SummaryBars from "./src/components/SummaryBars";
|
||||
import { CATEGORIES } from "./src/categories";
|
||||
import {
|
||||
addDaysStr,
|
||||
groupKey,
|
||||
groupTitle,
|
||||
money,
|
||||
mondayOf,
|
||||
nextDue,
|
||||
seedSamples,
|
||||
todayStr,
|
||||
uid,
|
||||
} from "./src/logic";
|
||||
import { loadExpenses, saveExpenses } from "./src/storage";
|
||||
import s from "./src/styles";
|
||||
|
||||
const TABS = ["daily", "weekly", "monthly"];
|
||||
const SCOPE_NOUN = { daily: "day", weekly: "week", monthly: "month" };
|
||||
|
||||
function blankForm() {
|
||||
return { description: "", amount: "", category: "Food", date: todayStr(), frequency: "once" };
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [expenses, setExpenses] = useState([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [tab, setTab] = useState("daily");
|
||||
const [query, setQuery] = useState("");
|
||||
const [catFilter, setCatFilter] = useState("all");
|
||||
const [freqFilter, setFreqFilter] = useState("all");
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState(null);
|
||||
const currency = "INR";
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
/* Display font for headings; headings fall back to the system font
|
||||
until it loads (or when offline), so rendering is never blocked. */
|
||||
useFonts({ PetitFormalScript_400Regular, ShadowsIntoLightTwo_400Regular, StyleScript_400Regular, Waterfall_400Regular });
|
||||
|
||||
useEffect(() => {
|
||||
loadExpenses().then((list) => {
|
||||
setExpenses(list);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function update(list) {
|
||||
setExpenses(list);
|
||||
saveExpenses(list);
|
||||
}
|
||||
|
||||
const editing = editingId ? expenses.find((e) => e.id === editingId) || null : null;
|
||||
|
||||
function submitForm(data) {
|
||||
if (editing) {
|
||||
update(expenses.map((e) => (e.id === editing.id ? { ...e, ...data } : e)));
|
||||
setEditingId(null);
|
||||
} else {
|
||||
update([...expenses, { ...data, id: uid(), lastLogged: null, createdAt: Date.now() }]);
|
||||
}
|
||||
}
|
||||
|
||||
function requestDelete(id) {
|
||||
if (pendingDeleteId === id) {
|
||||
update(expenses.filter((e) => e.id !== id));
|
||||
if (editingId === id) setEditingId(null);
|
||||
setPendingDeleteId(null);
|
||||
} else {
|
||||
setPendingDeleteId(id);
|
||||
}
|
||||
}
|
||||
|
||||
function logNext(id) {
|
||||
const t = expenses.find((e) => e.id === id);
|
||||
if (!t) return;
|
||||
const due = nextDue(t);
|
||||
update([
|
||||
...expenses.map((e) => (e.id === id ? { ...e, lastLogged: due } : e)),
|
||||
{
|
||||
id: uid(),
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
category: t.category,
|
||||
date: due,
|
||||
frequency: "once",
|
||||
lastLogged: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return expenses.filter((e) => {
|
||||
if (catFilter !== "all" && e.category !== catFilter) return false;
|
||||
if (freqFilter !== "all" && e.frequency !== freqFilter) return false;
|
||||
if (q && (e.description + " " + e.category).toLowerCase().indexOf(q) === -1) return false;
|
||||
return true;
|
||||
});
|
||||
}, [expenses, query, catFilter, freqFilter]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = {};
|
||||
visible.forEach((e) => {
|
||||
const k = groupKey(e, tab);
|
||||
(map[k] = map[k] || []).push(e);
|
||||
});
|
||||
return Object.keys(map)
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((k) => {
|
||||
const items = map[k].slice().sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : b.createdAt - a.createdAt));
|
||||
return { key: k, title: groupTitle(k, tab), total: items.reduce((sum, e) => sum + e.amount, 0), items };
|
||||
});
|
||||
}, [visible, tab]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const t = todayStr();
|
||||
const wk = mondayOf(t);
|
||||
const mo = t.slice(0, 7);
|
||||
let d = 0;
|
||||
let w = 0;
|
||||
let m = 0;
|
||||
let dc = 0;
|
||||
let wc = 0;
|
||||
let mc = 0;
|
||||
const cats = {};
|
||||
expenses.forEach((e) => {
|
||||
if (e.date === t) { d += e.amount; dc += 1; }
|
||||
if (mondayOf(e.date) === wk) { w += e.amount; wc += 1; }
|
||||
if (String(e.date).slice(0, 7) === mo) { m += e.amount; mc += 1; }
|
||||
cats[e.category] = (cats[e.category] || 0) + 1;
|
||||
});
|
||||
return { d, w, m, counts: { daily: dc, weekly: wc, monthly: mc }, catCounts: cats };
|
||||
}, [expenses]);
|
||||
|
||||
function editExpense(id) {
|
||||
setEditingId(id);
|
||||
setPendingDeleteId(null);
|
||||
if (scrollRef.current && scrollRef.current.scrollTo) scrollRef.current.scrollTo({ y: 0, animated: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
<StatusBar style="auto" />
|
||||
<ScrollView ref={scrollRef} style={s.scroll} contentContainerStyle={s.container}>
|
||||
<View style={s.header}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text style={s.headerTitle}>Pin & Penny</Text>
|
||||
<Pressable onPress={() => setMenuOpen(!menuOpen)} accessibilityLabel={menuOpen ? "Close menu" : "Open records menu"}>
|
||||
<Text style={[s.headerTotal, { fontSize: 20 }]}>{menuOpen ? "\u2715" : "\u2630"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={s.headerTotal}>{money(stats.m, currency)} this month</Text>
|
||||
</View>
|
||||
|
||||
<View style={s.card}>
|
||||
<Text style={s.cardTitle}>{editing ? "Edit expense" : "Add expense"}</Text>
|
||||
{!ready ? (
|
||||
<Text style={s.emptyNote}>Loading…</Text>
|
||||
) : (
|
||||
<ExpenseForm
|
||||
key={editing ? editing.id : "new"}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
description: editing.description,
|
||||
amount: String(editing.amount),
|
||||
category: editing.category,
|
||||
date: editing.date,
|
||||
frequency: editing.frequency,
|
||||
}
|
||||
: blankForm()
|
||||
}
|
||||
submitLabel={editing ? "Save changes" : "Add expense"}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing ? () => setEditingId(null) : null}
|
||||
currency={currency}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{menuOpen ? (
|
||||
<>
|
||||
<View style={s.statsRow}>
|
||||
<View style={s.stat}>
|
||||
<Text style={s.statLabel}>Today</Text>
|
||||
<Text style={s.statVal}>{money(stats.d, currency)}</Text>
|
||||
</View>
|
||||
<View style={s.stat}>
|
||||
<Text style={s.statLabel}>This week</Text>
|
||||
<Text style={s.statVal}>{money(stats.w, currency)}</Text>
|
||||
</View>
|
||||
<View style={s.stat}>
|
||||
<Text style={s.statLabel}>This month</Text>
|
||||
<Text style={s.statVal}>{money(stats.m, currency)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={s.card}>
|
||||
<View style={s.tabs}>
|
||||
{TABS.map((t) => (
|
||||
<Pressable key={t} onPress={() => setTab(t)} style={[s.tab, tab === t && s.tabActive]}>
|
||||
<Text style={[s.tabText, tab === t && s.tabTextActive]}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)} {"\u00b7"} {stats.counts[t]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<View style={s.searchRow}>
|
||||
<TextInput value={query} onChangeText={setQuery} placeholder="Search expenses…" style={s.input} />
|
||||
</View>
|
||||
<Text style={s.label}>Category</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.chipRow}>
|
||||
<View style={[s.chips, { marginTop: 0 }]}>
|
||||
<Pressable onPress={() => setCatFilter("all")} style={[s.chip, catFilter === "all" && s.chipActive]}>
|
||||
<Text style={[s.chipText, catFilter === "all" && s.chipTextActive]}>All</Text>
|
||||
</Pressable>
|
||||
{CATEGORIES.map((c) => (
|
||||
<Pressable key={c.name} onPress={() => setCatFilter(c.name)} style={[s.chip, catFilter === c.name && s.chipActive]}>
|
||||
<Text style={[s.chipText, catFilter === c.name && s.chipTextActive]}>{"\u25cf"} {c.name} <Text style={s.chipCount}>{"\u00b7"} {stats.catCounts[c.name] || 0}</Text></Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<Text style={s.label}>Repeats</Text>
|
||||
<View style={s.chips}>
|
||||
{["all", "once", "daily", "weekly", "monthly"].map((f) => (
|
||||
<Pressable key={f} onPress={() => setFreqFilter(f)} style={[s.chip, freqFilter === f && s.chipActive]}>
|
||||
<Text style={[s.chipText, freqFilter === f && s.chipTextActive]}>
|
||||
{f === "all" ? "All" : f === "once" ? "One-time" : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={s.card}>
|
||||
<Text style={s.cardTitle}>{tab.charAt(0).toUpperCase() + tab.slice(1)} summary</Text>
|
||||
<SummaryBars entries={visible} scopeNoun={SCOPE_NOUN[tab]} currency={currency} />
|
||||
</View>
|
||||
|
||||
<ExpenseGroups groups={groups} pendingDeleteId={pendingDeleteId} onEdit={editExpense} onDeleteRequest={requestDelete} currency={currency} />
|
||||
|
||||
<RepeatTemplates expenses={expenses} onLog={logNext} currency={currency} />
|
||||
|
||||
<View style={[s.card, { flexDirection: "row", gap: 8 }]}>
|
||||
<Pressable onPress={() => update([...expenses, ...seedSamples()])} style={[s.btn, s.btnSubtle, { flex: 1 }]}>
|
||||
<Text style={s.btnTextDark}>Load sample data</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setEditingId(null);
|
||||
setPendingDeleteId(null);
|
||||
update([]);
|
||||
}}
|
||||
style={[s.btn, s.btnSubtle, { flex: 1 }]}
|
||||
>
|
||||
<Text style={[s.btnTextDark, { color: "#b91c1c" }]}>Clear all</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Text style={s.foot}>Saved privately on this device. Yesterday: {addDaysStr(todayStr(), -1)}</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
53
expo-app/README.md
Normal file
53
expo-app/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Pin & Penny — Expo app (web + mobile from one codebase)
|
||||
|
||||
React Native (Expo SDK 57) version of the expense tracker. The same code runs as a
|
||||
website, an iOS app and an Android app. Data is stored on-device with AsyncStorage,
|
||||
so it works on all three without a backend.
|
||||
|
||||
The original plain-web version still lives in the folder above this one and keeps
|
||||
working as before; this folder is the future-proof rewrite.
|
||||
|
||||
## Screens and features
|
||||
|
||||
- **Daily / Weekly / Monthly tabs** — the same expenses regrouped by day, by week
|
||||
(Monday–Sunday) or by month, each group with its own total.
|
||||
- **Categories** — Food, Transport, Housing, Utilities, Shopping, Health,
|
||||
Entertainment, Other.
|
||||
- **Repeats** — mark an expense Daily, Weekly or Monthly. They appear under
|
||||
“Repeating expenses” with the next due date and a **Log next occurrence** button.
|
||||
- **Summary** — totals plus a per-category spending breakdown of whatever is shown.
|
||||
- **INR amounts** — everything is shown in rupees (₹) with Indian digit grouping.
|
||||
- **Search and filters**, **edit**, and two-tap **delete** (no popups, works
|
||||
identically on web and phones).
|
||||
- **Sample data** on first run; **Clear all** to start empty.
|
||||
|
||||
## Run it (needs Node.js 20+ — not available in this workspace)
|
||||
|
||||
```sh
|
||||
cd expo-app
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- **Web** — press `w` in the terminal, or run `npm run web`. Opens the app in your browser.
|
||||
- **Phone** — install the free **Expo Go** app, then scan the QR code the terminal shows.
|
||||
- **Simulators** — press `a` (Android Studio emulator) or `i` (Xcode simulator, Mac only).
|
||||
|
||||
If versions ever drift, `npx expo install --fix` realigns them with the SDK.
|
||||
|
||||
## Going further later
|
||||
|
||||
- **Installable phone apps** (App Store / Play Store): `npm install -g eas-cli`, then
|
||||
`eas build -p android` / `eas build -p ios`. No code changes needed.
|
||||
- **Backend sync** (same data on web and phone): add any API later; only
|
||||
`src/storage.js` needs to change — the UI and `src/logic.js` stay untouched.
|
||||
|
||||
## Files
|
||||
|
||||
- `App.js` — screen state and layout
|
||||
- `src/logic.js` — dates, totals, grouping, repeats (plain JS, no platform imports)
|
||||
- `src/storage.js` — on-device persistence (AsyncStorage)
|
||||
- `src/components/` — form, summary bars, grouped list, repeating templates
|
||||
- `package.json` — every version pinned to a release verified on the npm registry
|
||||
16
expo-app/app.json
Normal file
16
expo-app/app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Pin & Penny",
|
||||
"slug": "expense-tracker",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"newArchEnabled": true,
|
||||
"platforms": ["ios", "android", "web"],
|
||||
"web": {
|
||||
"bundler": "metro"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
3
expo-app/babel.config.js
Normal file
3
expo-app/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
4
expo-app/index.js
Normal file
4
expo-app/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import App from "./App";
|
||||
|
||||
registerRootComponent(App);
|
||||
6305
expo-app/package-lock.json
generated
Normal file
6305
expo-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
expo-app/package.json
Normal file
30
expo-app/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "expense-tracker",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/petit-formal-script": "0.4.1",
|
||||
"@expo-google-fonts/shadows-into-light-two": "0.4.1",
|
||||
"@expo-google-fonts/style-script": "0.4.2",
|
||||
"@expo-google-fonts/waterfall": "0.4.2",
|
||||
"@expo/metro-runtime": "57.0.15",
|
||||
"@react-native-async-storage/async-storage": "3.1.1",
|
||||
"expo": "57.0.23",
|
||||
"expo-font": "57.0.4",
|
||||
"expo-status-bar": "57.0.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-web": "0.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
17
expo-app/src/categories.js
Normal file
17
expo-app/src/categories.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/* Expense categories with their chart/dot colors. */
|
||||
|
||||
export const CATEGORIES = [
|
||||
{ name: "Food", color: "#9ed6a4" },
|
||||
{ name: "Transport", color: "#a9c8f0" },
|
||||
{ name: "Housing", color: "#eab88f" },
|
||||
{ name: "Utilities", color: "#8fd8c4" },
|
||||
{ name: "Shopping", color: "#f5b98a" },
|
||||
{ name: "Health", color: "#f2a3a3" },
|
||||
{ name: "Entertainment", color: "#f2cd88" },
|
||||
{ name: "Other", color: "#c9c9c9" },
|
||||
];
|
||||
|
||||
export function colorFor(category) {
|
||||
const found = CATEGORIES.find((c) => c.name === category);
|
||||
return found ? found.color : "#c9c9c9";
|
||||
}
|
||||
90
expo-app/src/components/ExpenseForm.js
Normal file
90
expo-app/src/components/ExpenseForm.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { CATEGORIES } from "../categories";
|
||||
import { CURRENCY_SYMBOLS, FREQS, FREQ_LABEL, parseDay, todayStr } from "../logic";
|
||||
import s from "../styles";
|
||||
|
||||
function Chip({ label, active, onPress, dot }) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={[s.chip, active && s.chipActive]}>
|
||||
<Text style={[s.chipText, active && s.chipTextActive]}>
|
||||
{dot ? "\u25cf " : ""}
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExpenseForm({ initial, submitLabel, onSubmit, onCancel, currency }) {
|
||||
const [desc, setDesc] = useState(initial.description);
|
||||
const [amount, setAmount] = useState(initial.amount);
|
||||
const [cat, setCat] = useState(initial.category);
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [freq, setFreq] = useState(initial.frequency);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
function submit() {
|
||||
const d = desc.trim();
|
||||
const a = Math.round(parseFloat(amount) * 100) / 100;
|
||||
if (!d) return setErr("Please enter a description.");
|
||||
if (!(a > 0)) return setErr("Please enter an amount greater than zero.");
|
||||
if (!parseDay(date)) return setErr("Please use a real date like " + todayStr() + ".");
|
||||
setErr("");
|
||||
onSubmit({ description: d, amount: a, category: cat, date, frequency: freq });
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={s.label}>Description</Text>
|
||||
<TextInput value={desc} onChangeText={setDesc} placeholder="e.g. Groceries" maxLength={80} style={s.input} />
|
||||
|
||||
<View style={s.row2}>
|
||||
<View style={s.half}>
|
||||
<Text style={s.label}>Amount ({CURRENCY_SYMBOLS[currency] || "$"})</Text>
|
||||
<TextInput
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
placeholder="0.00"
|
||||
keyboardType="decimal-pad"
|
||||
style={s.input}
|
||||
/>
|
||||
</View>
|
||||
<View style={s.half}>
|
||||
<Text style={s.label}>Date</Text>
|
||||
<TextInput value={date} onChangeText={setDate} placeholder="YYYY-MM-DD" maxLength={10} style={s.input} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={() => setDate(todayStr())} style={[s.btn, s.btnSubtle, { marginTop: 8, alignSelf: "flex-start" }]}>
|
||||
<Text style={s.btnTextDark}>Use today</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={s.label}>Category</Text>
|
||||
<View style={s.chips}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<Chip key={c.name} label={c.name} dot active={cat === c.name} onPress={() => setCat(c.name)} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={s.label}>Repeats</Text>
|
||||
<View style={s.chips}>
|
||||
{FREQS.map((f) => (
|
||||
<Chip key={f} label={FREQ_LABEL[f]} active={freq === f} onPress={() => setFreq(f)} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
<View style={s.btnRow}>
|
||||
<Pressable onPress={submit} style={[s.btn, s.btnPrimary]}>
|
||||
<Text style={s.btnText}>{submitLabel}</Text>
|
||||
</Pressable>
|
||||
{onCancel ? (
|
||||
<Pressable onPress={onCancel} style={[s.btn, s.btnSubtle]}>
|
||||
<Text style={s.btnTextDark}>Cancel</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
57
expo-app/src/components/ExpenseGroups.js
Normal file
57
expo-app/src/components/ExpenseGroups.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { colorFor } from "../categories";
|
||||
import { FREQ_LABEL, fmtDay, money } from "../logic";
|
||||
import s from "../styles";
|
||||
|
||||
export default function ExpenseGroups({ groups, pendingDeleteId, onEdit, onDeleteRequest, currency }) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<View style={s.card}>
|
||||
<Text style={s.emptyNote}>No expenses yet. Add your first one above.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View>
|
||||
{groups.map((g) => (
|
||||
<View key={g.key} style={s.group}>
|
||||
<View style={s.groupHead}>
|
||||
<Text style={s.groupTitle}>{g.title}</Text>
|
||||
<Text style={s.groupTotal}>{money(g.total, currency)}</Text>
|
||||
</View>
|
||||
{g.items.map((e, i) => {
|
||||
const confirming = pendingDeleteId === e.id;
|
||||
return (
|
||||
<View key={e.id} style={[s.item, i === 0 && { borderTopWidth: 0 }]}>
|
||||
<View style={[s.dot, { backgroundColor: colorFor(e.category) }]} />
|
||||
<View style={s.itemMain}>
|
||||
<Text style={s.itemDesc}>
|
||||
{e.description}
|
||||
{e.frequency !== "once" ? <Text style={s.freqTag}> {"\u00b7"} {FREQ_LABEL[e.frequency]}</Text> : null}
|
||||
</Text>
|
||||
<Text style={s.itemMeta}>
|
||||
<Text style={s.subInline}>{e.category}</Text> {"\u00b7"} {fmtDay(e.date)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={s.itemAmt}>{money(e.amount, currency)}</Text>
|
||||
<View style={s.itemOps}>
|
||||
<Pressable onPress={() => onEdit(e.id)} style={s.linkBtn}>
|
||||
<Text style={[s.small, s.editLink]}>Edit</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => onDeleteRequest(e.id)}
|
||||
style={[s.linkBtn, confirming && s.confirmBg]}
|
||||
>
|
||||
<Text style={confirming ? s.linkConfirm : s.linkDanger}>
|
||||
{confirming ? "Sure?" : "Delete"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
37
expo-app/src/components/RepeatTemplates.js
Normal file
37
expo-app/src/components/RepeatTemplates.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { FREQ_LABEL, fmtDay, money, nextDue } from "../logic";
|
||||
import s from "../styles";
|
||||
|
||||
export default function RepeatTemplates({ expenses, onLog, currency }) {
|
||||
const temps = expenses
|
||||
.filter((e) => e.frequency !== "once")
|
||||
.slice()
|
||||
.sort((a, b) => (nextDue(a) < nextDue(b) ? -1 : 1));
|
||||
|
||||
return (
|
||||
<View style={s.card}>
|
||||
<Text style={s.cardTitle}>Repeating expenses</Text>
|
||||
<Text style={[s.muted, s.small, { marginBottom: 10 }]}>
|
||||
Templates for bills and habits. Use Log to record each occurrence as it happens.
|
||||
</Text>
|
||||
{temps.length === 0 ? (
|
||||
<Text style={s.emptyNote}>None yet. Set Repeats to Daily, Weekly or Monthly when adding an expense.</Text>
|
||||
) : (
|
||||
temps.map((t) => (
|
||||
<View key={t.id} style={s.template}>
|
||||
<View style={s.templateTop}>
|
||||
<Text style={s.templateName}>{t.description}</Text>
|
||||
<Text style={s.itemAmt}>{money(t.amount, currency)}</Text>
|
||||
</View>
|
||||
<Text style={s.templateNext}>
|
||||
<Text style={s.subInline}>{t.category}</Text> {"\u00b7"} repeats {FREQ_LABEL[t.frequency].toLowerCase()} {"\u00b7"} next {fmtDay(nextDue(t))}
|
||||
</Text>
|
||||
<Pressable onPress={() => onLog(t.id)} style={[s.btn, s.btnSubtle, s.btnSmall, { alignSelf: "flex-start" }]}>
|
||||
<Text style={s.btnSmallText}>Log next occurrence</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
38
expo-app/src/components/SummaryBars.js
Normal file
38
expo-app/src/components/SummaryBars.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { colorFor } from "../categories";
|
||||
import { money } from "../logic";
|
||||
import s from "../styles";
|
||||
|
||||
export default function SummaryBars({ entries, scopeNoun, currency }) {
|
||||
let total = 0;
|
||||
const byCat = {};
|
||||
entries.forEach((e) => {
|
||||
total += e.amount;
|
||||
byCat[e.category] = (byCat[e.category] || 0) + e.amount;
|
||||
});
|
||||
const names = Object.keys(byCat).sort((a, b) => byCat[b] - byCat[a]);
|
||||
const max = names.length ? byCat[names[0]] : 0;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={s.sumLine}>
|
||||
{entries.length} shown {"\u00b7"} {money(total, currency)} total, grouped by {scopeNoun}
|
||||
</Text>
|
||||
{names.length === 0 ? (
|
||||
<Text style={s.emptyNote}>Nothing matches the current filters.</Text>
|
||||
) : (
|
||||
names.map((n) => (
|
||||
<View key={n} style={s.barRow}>
|
||||
<Text style={s.barName} numberOfLines={1}>
|
||||
{n}
|
||||
</Text>
|
||||
<View style={s.barTrack}>
|
||||
<View style={[s.barFill, { width: `${Math.max(2, Math.round((byCat[n] / max) * 100))}%`, backgroundColor: colorFor(n) }]} />
|
||||
</View>
|
||||
<Text style={s.barAmt}>{money(byCat[n], currency)}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
183
expo-app/src/logic.js
Normal file
183
expo-app/src/logic.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/* Pure expense-tracker logic: dates, money, grouping, repeats, seeds.
|
||||
No platform imports, so it runs unchanged on web, iOS and Android. */
|
||||
|
||||
const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const MONTHS_LONG = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
const DAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export const FREQ_LABEL = { once: "One-time", daily: "Daily", weekly: "Weekly", monthly: "Monthly" };
|
||||
export const FREQS = ["once", "daily", "weekly", "monthly"];
|
||||
|
||||
export function uid() {
|
||||
return Date.now().toString(36) + Math.floor(Math.random() * 0xffffff).toString(36);
|
||||
}
|
||||
|
||||
function pad(n) {
|
||||
return (n < 10 ? "0" : "") + n;
|
||||
}
|
||||
|
||||
export function toDateStr(d) {
|
||||
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
|
||||
}
|
||||
|
||||
export function todayStr() {
|
||||
return toDateStr(new Date());
|
||||
}
|
||||
|
||||
/* Strict YYYY-MM-DD parse; returns a Date or null. */
|
||||
export function parseDay(s) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ""));
|
||||
if (!m) return null;
|
||||
const d = new Date(+m[1], +m[2] - 1, +m[3]);
|
||||
if (d.getFullYear() !== +m[1] || d.getMonth() !== +m[2] - 1 || d.getDate() !== +m[3]) return null;
|
||||
return d;
|
||||
}
|
||||
|
||||
export function addDaysStr(s, n) {
|
||||
const d = parseDay(s);
|
||||
if (!d) return s;
|
||||
d.setDate(d.getDate() + n);
|
||||
return toDateStr(d);
|
||||
}
|
||||
|
||||
export function fmtDay(s) {
|
||||
const d = parseDay(s);
|
||||
if (!d) return String(s);
|
||||
return MONTHS_SHORT[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear();
|
||||
}
|
||||
|
||||
export function fmtWeekday(s) {
|
||||
const d = parseDay(s);
|
||||
return d ? DAYS_LONG[d.getDay()] : "";
|
||||
}
|
||||
|
||||
/* Weeks start on Monday; the key is the Monday date string. */
|
||||
export function mondayOf(s) {
|
||||
const d = parseDay(s);
|
||||
if (!d) return s;
|
||||
const shift = (d.getDay() + 6) % 7;
|
||||
d.setDate(d.getDate() - shift);
|
||||
return toDateStr(d);
|
||||
}
|
||||
|
||||
export function fmtWeekRange(monday) {
|
||||
const a = parseDay(monday);
|
||||
if (!a) return String(monday);
|
||||
const b = new Date(a);
|
||||
b.setDate(b.getDate() + 6);
|
||||
const left = MONTHS_SHORT[a.getMonth()] + " " + a.getDate();
|
||||
const right =
|
||||
a.getMonth() === b.getMonth()
|
||||
? b.getDate() + ", " + b.getFullYear()
|
||||
: MONTHS_SHORT[b.getMonth()] + " " + b.getDate() + ", " + b.getFullYear();
|
||||
return left + " \u2013 " + right;
|
||||
}
|
||||
|
||||
export function fmtMonthTitle(key) {
|
||||
const m = /^(\d{4})-(\d{2})$/.exec(String(key || ""));
|
||||
if (!m) return String(key);
|
||||
return MONTHS_LONG[+m[2] - 1] + " " + m[1];
|
||||
}
|
||||
|
||||
/* INR-only display (stored amounts are never converted).
|
||||
Manual formatting without Intl, so output matches on every OS. */
|
||||
export const CURRENCIES = ["INR"];
|
||||
export const CURRENCY_SYMBOLS = { INR: "₹" };
|
||||
|
||||
function groupInt(int, currency) {
|
||||
if (currency === "INR") {
|
||||
// Indian grouping: final 3 digits, then pairs (12,34,567).
|
||||
const tail = int.slice(-3);
|
||||
let head = int.slice(0, -3);
|
||||
if (!head) return tail;
|
||||
const parts = [];
|
||||
while (head.length > 2) {
|
||||
parts.unshift(head.slice(-2));
|
||||
head = head.slice(0, -2);
|
||||
}
|
||||
if (head) parts.unshift(head);
|
||||
return parts.join(",") + "," + tail;
|
||||
}
|
||||
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
/* Deterministic currency formatting; unknown codes fall back to USD. */
|
||||
export function money(n, currency) {
|
||||
const code = CURRENCY_SYMBOLS[currency] ? currency : "USD";
|
||||
const sym = CURRENCY_SYMBOLS[code];
|
||||
const neg = n < 0;
|
||||
const factor = code === "JPY" ? 1 : 100;
|
||||
const v = Math.abs(Math.round(n * factor));
|
||||
const frac = code === "JPY" ? "" : "." + String(v % 100).padStart(2, "0");
|
||||
const int = groupInt(String(Math.floor(v / factor)), code);
|
||||
return (neg ? "-" : "") + sym + int + frac;
|
||||
}
|
||||
|
||||
export function nextDue(t) {
|
||||
const base = t.lastLogged || t.date;
|
||||
if (t.frequency === "daily") return addDaysStr(base, 1);
|
||||
if (t.frequency === "weekly") return addDaysStr(base, 7);
|
||||
const d = parseDay(base);
|
||||
if (!d) return base;
|
||||
const day = d.getDate();
|
||||
d.setDate(1);
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||
d.setDate(Math.min(day, last));
|
||||
return toDateStr(d);
|
||||
}
|
||||
|
||||
export function groupKey(exp, tab) {
|
||||
if (tab === "daily") return exp.date;
|
||||
if (tab === "weekly") return mondayOf(exp.date);
|
||||
return String(exp.date).slice(0, 7);
|
||||
}
|
||||
|
||||
export function groupTitle(key, tab) {
|
||||
if (tab === "daily") {
|
||||
const t = todayStr();
|
||||
let label = fmtDay(key);
|
||||
if (key === t) label += " (today)";
|
||||
else if (key === addDaysStr(t, -1)) label += " (yesterday)";
|
||||
return label + " \u00b7 " + fmtWeekday(key);
|
||||
}
|
||||
if (tab === "weekly") return "Week of " + fmtWeekRange(key);
|
||||
return fmtMonthTitle(key);
|
||||
}
|
||||
|
||||
/* Sample data relative to today so every view has content on first run. */
|
||||
export function seedSamples() {
|
||||
const t = todayStr();
|
||||
const first = t.slice(0, 8) + "01";
|
||||
const now = Date.now();
|
||||
function mk(desc, amount, cat, date, freq) {
|
||||
return {
|
||||
id: uid() + Math.floor(Math.random() * 1e6).toString(36),
|
||||
description: desc,
|
||||
amount,
|
||||
category: cat,
|
||||
date,
|
||||
frequency: freq || "once",
|
||||
lastLogged: null,
|
||||
createdAt: now,
|
||||
};
|
||||
}
|
||||
return [
|
||||
mk("Groceries", 54.2, "Food", t),
|
||||
mk("Bus pass", 28.0, "Transport", t, "monthly"),
|
||||
mk("Coffee", 4.5, "Food", addDaysStr(t, -1)),
|
||||
mk("Pharmacy", 12.75, "Health", addDaysStr(t, -2)),
|
||||
mk("Electricity bill", 85.4, "Utilities", addDaysStr(t, -3), "monthly"),
|
||||
mk("Movie night", 16.0, "Entertainment", addDaysStr(t, -5)),
|
||||
mk("Gym", 35.0, "Health", addDaysStr(t, -6), "monthly"),
|
||||
mk("New shoes", 79.99, "Shopping", addDaysStr(t, -8)),
|
||||
mk("Team lunch", 13.4, "Food", addDaysStr(t, -9), "weekly"),
|
||||
mk("Internet", 59.99, "Utilities", addDaysStr(t, -12), "monthly"),
|
||||
mk("Rent", 1200.0, "Housing", first, "monthly"),
|
||||
mk("Concert ticket", 45.0, "Entertainment", addDaysStr(t, -20)),
|
||||
mk("Groceries", 62.1, "Food", addDaysStr(t, -33)),
|
||||
];
|
||||
}
|
||||
29
expo-app/src/storage.js
Normal file
29
expo-app/src/storage.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/* Persistence via AsyncStorage, which works on web, iOS and Android. */
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { seedSamples } from "./logic";
|
||||
|
||||
const KEY = "expense-tracker.expenses.v1";
|
||||
|
||||
export async function loadExpenses() {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (raw === null) {
|
||||
const seed = seedSamples();
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(seed));
|
||||
return seed;
|
||||
}
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveExpenses(list) {
|
||||
try {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(list));
|
||||
} catch (e) {
|
||||
// Storage full or unavailable; the in-memory list still works this session.
|
||||
}
|
||||
}
|
||||
114
expo-app/src/styles.js
Normal file
114
expo-app/src/styles.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
/* The Tasks app "Cute" theme, matching web styles.css: soft pink canvas,
|
||||
lavender drawer, gentle bright-pink accent, deep plum ink.
|
||||
DISPLAY = headings/totals, BODY = all smaller text (kept at normal weight
|
||||
since the body face ships a single regular cut). */
|
||||
export const palette = {
|
||||
bg: "#ffe3ee",
|
||||
card: "#fffafc",
|
||||
ink: "#6e3350",
|
||||
muted: "#a76a89",
|
||||
line: "#d5aec0",
|
||||
header: "#6e3350",
|
||||
headerText: "#fff8e7",
|
||||
accent: "#ff6fae",
|
||||
accentDark: "#ff4f9a",
|
||||
accentText: "#d63384",
|
||||
danger: "#b91c1c",
|
||||
track: "#ffe0ee",
|
||||
inputBg: "#fffafc",
|
||||
};
|
||||
|
||||
const DISPLAY = "PetitFormalScript_400Regular";
|
||||
const BODY = "ShadowsIntoLightTwo_400Regular";
|
||||
const SUB = "StyleScript_400Regular";
|
||||
const BTN = "Waterfall_400Regular";
|
||||
|
||||
export default StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: palette.bg },
|
||||
scroll: { flex: 1 },
|
||||
container: { padding: 16, paddingBottom: 48, maxWidth: 720, width: "100%", alignSelf: "center" },
|
||||
|
||||
header: { backgroundColor: "#ffd3e3", borderRadius: 12, padding: 18, marginBottom: 12 },
|
||||
headerTitle: { color: palette.header, fontSize: 27, fontWeight: "700", fontFamily: DISPLAY },
|
||||
headerTotal: { color: palette.accentText, fontSize: 15, fontWeight: "700", marginTop: 8, fontFamily: DISPLAY },
|
||||
|
||||
card: { backgroundColor: palette.card, borderColor: palette.line, borderWidth: 1, borderRadius: 12, padding: 16, marginBottom: 12 },
|
||||
cardTitle: { fontSize: 20, fontWeight: "700", color: palette.ink, marginBottom: 10, fontFamily: DISPLAY },
|
||||
|
||||
statsRow: { flexDirection: "row", gap: 10, marginBottom: 12 },
|
||||
stat: { flex: 1, backgroundColor: palette.card, borderColor: palette.line, borderWidth: 1, borderRadius: 12, padding: 12 },
|
||||
statLabel: { color: palette.muted, fontSize: 12, fontFamily: SUB },
|
||||
statVal: { fontSize: 23, fontWeight: "700", color: palette.ink, marginTop: 2, fontVariant: ["tabular-nums"], fontFamily: DISPLAY },
|
||||
|
||||
tabs: { flexDirection: "row", backgroundColor: "#ffd9e8", borderRadius: 999, padding: 4, marginBottom: 12 },
|
||||
tab: { flex: 1, paddingVertical: 8, borderRadius: 999, alignItems: "center" },
|
||||
tabActive: { backgroundColor: palette.header },
|
||||
tabText: { color: palette.muted, fontWeight: "400", fontFamily: SUB },
|
||||
tabTextActive: { color: "#fff" },
|
||||
|
||||
label: { color: palette.muted, fontSize: 13, marginBottom: 4, marginTop: 10, fontFamily: SUB },
|
||||
input: {
|
||||
backgroundColor: palette.inputBg, borderColor: palette.line, borderWidth: 1,
|
||||
borderRadius: 8, paddingHorizontal: 10, paddingVertical: 9, fontSize: 15, color: palette.ink,
|
||||
fontFamily: BODY,
|
||||
},
|
||||
row2: { flexDirection: "row", gap: 10 },
|
||||
half: { flex: 1 },
|
||||
err: { color: palette.danger, fontSize: 13, marginTop: 8, fontFamily: BODY },
|
||||
|
||||
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 6 },
|
||||
chip: { borderWidth: 1, borderColor: palette.line, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7, backgroundColor: palette.inputBg },
|
||||
chipActive: { backgroundColor: palette.header, borderColor: palette.header },
|
||||
chipText: { color: palette.muted, fontWeight: "400", fontSize: 13, fontFamily: SUB },
|
||||
chipCount: { fontSize: 13, fontFamily: BODY },
|
||||
chipTextActive: { color: "#fff" },
|
||||
chipRow: { marginBottom: 4 },
|
||||
|
||||
btnRow: { flexDirection: "row", gap: 8, marginTop: 14 },
|
||||
btn: { borderRadius: 9, paddingHorizontal: 14, paddingVertical: 10, alignItems: "center" },
|
||||
btnPrimary: { backgroundColor: palette.accent, flex: 1 },
|
||||
btnSubtle: { backgroundColor: "#ffe0ee", borderColor: palette.line, borderWidth: 1 },
|
||||
btnText: { color: "#fff", fontWeight: "400", fontSize: 17, fontFamily: BTN },
|
||||
btnTextDark: { color: palette.ink, fontWeight: "400", fontSize: 17, fontFamily: BTN },
|
||||
btnSmall: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 7 },
|
||||
btnSmallText: { fontSize: 15, fontWeight: "400", color: palette.ink, fontFamily: BTN },
|
||||
linkBtn: { paddingHorizontal: 10, paddingVertical: 6 },
|
||||
editLink: { color: palette.accentText, fontWeight: "400", fontSize: 16, fontFamily: BTN },
|
||||
linkDanger: { color: palette.danger, fontWeight: "400", fontSize: 16, fontFamily: BTN },
|
||||
linkConfirm: { color: "#fff", fontWeight: "400", fontSize: 16, fontFamily: BTN },
|
||||
confirmBg: { backgroundColor: palette.danger, borderRadius: 7 },
|
||||
|
||||
sumLine: { color: palette.muted, fontSize: 13, marginBottom: 10, fontFamily: BODY },
|
||||
barRow: { flexDirection: "row", alignItems: "center", gap: 10, marginBottom: 8 },
|
||||
barName: { width: 110, fontSize: 13, color: palette.ink, fontFamily: SUB },
|
||||
barTrack: { flex: 1, backgroundColor: palette.track, borderRadius: 6, height: 12, overflow: "hidden" },
|
||||
barFill: { height: 12, borderRadius: 6 },
|
||||
barAmt: { width: 76, textAlign: "right", fontSize: 13, fontWeight: "400", fontVariant: ["tabular-nums"], fontFamily: BODY },
|
||||
|
||||
group: { marginBottom: 12, borderRadius: 10, overflow: "hidden", borderColor: palette.line, borderWidth: 1 },
|
||||
groupHead: { backgroundColor: palette.header, paddingHorizontal: 14, paddingVertical: 10, flexDirection: "row", justifyContent: "space-between", alignItems: "center", gap: 10 },
|
||||
groupTitle: { color: palette.headerText, fontSize: 17, fontWeight: "700", flex: 1, fontFamily: DISPLAY },
|
||||
groupTotal: { color: palette.headerText, fontWeight: "700", fontVariant: ["tabular-nums"], fontFamily: DISPLAY },
|
||||
item: { flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 14, paddingVertical: 11, backgroundColor: palette.card, borderTopWidth: 1, borderTopColor: palette.line },
|
||||
dot: { width: 11, height: 11, borderRadius: 6 },
|
||||
itemMain: { flex: 1 },
|
||||
itemDesc: { fontWeight: "400", color: palette.ink, fontSize: 14, fontFamily: BODY },
|
||||
freqTag: { fontSize: 11, fontWeight: "400", color: palette.accentText, fontFamily: SUB },
|
||||
itemMeta: { color: palette.muted, fontSize: 12.5, marginTop: 1, fontFamily: BODY },
|
||||
itemAmt: { fontWeight: "400", fontVariant: ["tabular-nums"], color: palette.ink, fontFamily: BODY },
|
||||
itemOps: { flexDirection: "row", gap: 2, alignItems: "center" },
|
||||
|
||||
template: { borderColor: palette.line, borderWidth: 1, borderRadius: 8, padding: 10, backgroundColor: palette.inputBg, marginBottom: 8 },
|
||||
templateTop: { flexDirection: "row", justifyContent: "space-between", gap: 8 },
|
||||
templateName: { fontWeight: "400", color: palette.ink, flex: 1, fontFamily: BODY },
|
||||
templateNext: { color: palette.muted, fontSize: 12.5, marginVertical: 4, fontFamily: BODY },
|
||||
|
||||
muted: { color: palette.muted, fontFamily: BODY },
|
||||
subInline: { fontFamily: SUB },
|
||||
small: { fontSize: 13, fontFamily: BODY },
|
||||
emptyNote: { color: palette.muted, fontSize: 14, fontFamily: BODY },
|
||||
foot: { color: palette.muted, fontSize: 12.5, textAlign: "center", marginTop: 4, fontFamily: BODY },
|
||||
searchRow: { marginBottom: 4 },
|
||||
});
|
||||
Reference in New Issue
Block a user