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 ( Tally setMenuOpen(!menuOpen)} accessibilityLabel={menuOpen ? "Close menu" : "Open records menu"}> {menuOpen ? "\u2715" : "\u2630"} {money(stats.m, currency)} this month {editing ? "Edit expense" : "Add expense"} {!ready ? ( Loading… ) : ( setEditingId(null) : null} currency={currency} /> )} {menuOpen ? ( <> Today {money(stats.d, currency)} This week {money(stats.w, currency)} This month {money(stats.m, currency)} {TABS.map((t) => ( setTab(t)} style={[s.tab, tab === t && s.tabActive]}> {t.charAt(0).toUpperCase() + t.slice(1)} {"\u00b7"} {stats.counts[t]} ))} Category setCatFilter("all")} style={[s.chip, catFilter === "all" && s.chipActive]}> All {CATEGORIES.map((c) => ( setCatFilter(c.name)} style={[s.chip, catFilter === c.name && s.chipActive]}> {"\u25cf"} {c.name} {"\u00b7"} {stats.catCounts[c.name] || 0} ))} Repeats {["all", "once", "daily", "weekly", "monthly"].map((f) => ( setFreqFilter(f)} style={[s.chip, freqFilter === f && s.chipActive]}> {f === "all" ? "All" : f === "once" ? "One-time" : f.charAt(0).toUpperCase() + f.slice(1)} ))} {tab.charAt(0).toUpperCase() + tab.slice(1)} summary update([...expenses, ...seedSamples()])} style={[s.btn, s.btnSubtle, { flex: 1 }]}> Load sample data { setEditingId(null); setPendingDeleteId(null); update([]); }} style={[s.btn, s.btnSubtle, { flex: 1 }]} > Clear all ) : null} Saved privately on this device. Yesterday: {addDaysStr(todayStr(), -1)} ); }