feat(mobile): go fully native — native Tracker home over the JSON API (no WebView)
Replace the WebView SPA handoff with the app's own native screens that call the same JSON API — hitting the on-device server in local mode or the remote server in server mode (same code, base URL switches). Uses the api.ts client built earlier. - api.ts: typed getTracker/toggleBillPaid/createBill/logout + a shared authed mutate() helper (fetches + echoes the CSRF token); SessionUser type. - TrackerScreen.tsx: native monthly Tracker — month header, Paid/Needs-action/Bills/ Remaining stat tiles, prev/next month nav, and bill rows with a one-tap mark-paid toggle that re-fetches. Loading/error/empty states. Currency + dates via Intl in the WebView (Chromium — full ICU, unlike the embedded server). - MainApp.tsx + app.css: native app shell (top bar + avatar) and a bottom tab bar (Tracker built; Calendar/Insights/Account are placeholders/account for now). - App.tsx: renders MainApp instead of window.location.replace to the SPA; native auth for both modes (local login shows 'this device'); 401 bounces to login. Verified on the x86_64 emulator: two-doors setup -> native login -> native Tracker with live bills -> tap to mark paid -> tiles + row update. No WebView. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
27a76e92b8
commit
3ab76fbcdf
71
src/App.tsx
71
src/App.tsx
|
|
@ -7,69 +7,69 @@ import LoadingScreen from './LoadingScreen';
|
|||
import LoginScreen from './LoginScreen';
|
||||
import BiometricSetup from './BiometricSetup';
|
||||
import BiometricLock from './BiometricLock';
|
||||
import MainApp from './MainApp';
|
||||
|
||||
type Stage =
|
||||
| 'checking'
|
||||
| 'setup'
|
||||
| 'login'
|
||||
| 'biometric-setup'
|
||||
| 'biometric-lock'
|
||||
| 'local'
|
||||
| 'redirecting';
|
||||
type Stage = 'checking' | 'setup' | 'boot' | 'login' | 'biometric-setup' | 'biometric-lock' | 'app';
|
||||
type Mode = 'local' | 'server';
|
||||
|
||||
export default function App() {
|
||||
const [stage, setStage] = useState<Stage>('checking');
|
||||
const [mode, setMode] = useState<Mode>('server');
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [booted, setBooted] = useState(false);
|
||||
|
||||
// API base — the on-device server in local mode, or the remote server.
|
||||
const baseUrl = mode === 'local' ? LOCAL_URL : serverUrl;
|
||||
|
||||
useEffect(() => {
|
||||
Preferences.get({ key: PREF_SERVER_URL })
|
||||
.then(async ({ value }) => {
|
||||
if (value === LOCAL_MODE_SENTINEL) {
|
||||
setStage('local');
|
||||
setMode('local');
|
||||
setStage('boot');
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
setMode('server');
|
||||
setServerUrl(value);
|
||||
const { value: biometricEnabled } = await Preferences.get({ key: PREF_BIOMETRIC });
|
||||
setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting');
|
||||
const { value: bio } = await Preferences.get({ key: PREF_BIOMETRIC });
|
||||
setStage(bio === 'true' ? 'biometric-lock' : 'app');
|
||||
return;
|
||||
}
|
||||
setStage('setup');
|
||||
})
|
||||
// A failed preferences read must not leave us stuck on the spinner —
|
||||
// fall back to first-run setup so the user can (re)connect.
|
||||
.catch(() => setStage('setup'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage === 'redirecting') {
|
||||
window.location.replace(serverUrl);
|
||||
}
|
||||
}, [stage, serverUrl]);
|
||||
|
||||
function handleConnect(url: string) {
|
||||
setMode('server');
|
||||
setServerUrl(url);
|
||||
setStage('login');
|
||||
}
|
||||
|
||||
async function handleLocalMode() {
|
||||
await Preferences.set({ key: PREF_SERVER_URL, value: LOCAL_MODE_SENTINEL });
|
||||
setStage('local');
|
||||
setMode('local');
|
||||
setStage(booted ? 'app' : 'boot');
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
if (mode === 'server') {
|
||||
await Preferences.set({ key: PREF_SERVER_URL, value: serverUrl });
|
||||
}
|
||||
// Offer biometric unlock once, if the device supports it and it's undecided.
|
||||
try {
|
||||
const { isAvailable } = await BiometricAuth.checkBiometry();
|
||||
setStage(isAvailable ? 'biometric-setup' : 'redirecting');
|
||||
const { value: bioPref } = await Preferences.get({ key: PREF_BIOMETRIC });
|
||||
setStage(isAvailable && bioPref == null ? 'biometric-setup' : 'app');
|
||||
} catch {
|
||||
setStage('redirecting');
|
||||
setStage('app');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBiometricSetupDone(enabled: boolean) {
|
||||
await Preferences.set({ key: PREF_BIOMETRIC, value: enabled ? 'true' : 'false' });
|
||||
setStage('redirecting');
|
||||
setStage('app');
|
||||
}
|
||||
|
||||
async function handleBiometricFallback() {
|
||||
|
|
@ -77,9 +77,18 @@ export default function App() {
|
|||
setStage('login');
|
||||
}
|
||||
|
||||
function handleUnauthenticated() {
|
||||
setStage('login');
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await Preferences.remove({ key: PREF_BIOMETRIC });
|
||||
setStage('login');
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case 'local':
|
||||
return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
|
||||
case 'boot':
|
||||
return <LoadingScreen onReady={() => { setBooted(true); setStage('app'); }} />;
|
||||
|
||||
case 'setup':
|
||||
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
|
||||
|
|
@ -87,9 +96,9 @@ export default function App() {
|
|||
case 'login':
|
||||
return (
|
||||
<LoginScreen
|
||||
serverUrl={serverUrl}
|
||||
serverUrl={baseUrl}
|
||||
onSuccess={handleLoginSuccess}
|
||||
onBack={() => setStage('setup')}
|
||||
onBack={() => setStage(mode === 'local' ? 'app' : 'setup')}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -97,11 +106,13 @@ export default function App() {
|
|||
return <BiometricSetup onDone={handleBiometricSetupDone} />;
|
||||
|
||||
case 'biometric-lock':
|
||||
return <BiometricLock onUnlocked={() => setStage('redirecting')} onFallback={handleBiometricFallback} />;
|
||||
return <BiometricLock onUnlocked={() => setStage('app')} onFallback={handleBiometricFallback} />;
|
||||
|
||||
case 'app':
|
||||
return <MainApp baseUrl={baseUrl} onUnauthenticated={handleUnauthenticated} onSignOut={handleSignOut} />;
|
||||
|
||||
default:
|
||||
// 'checking' / 'redirecting' — brief blank while we check preferences
|
||||
// or hand off to the WebView.
|
||||
// 'checking' — brief blank while we read stored preferences.
|
||||
return (
|
||||
<div className="app-splash">
|
||||
<div className="spinner" />
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) {
|
|||
<div className="setup-root">
|
||||
<div className="setup-card">
|
||||
<h1 className="setup-title">Sign in</h1>
|
||||
<p className="setup-subtitle">Sign in to {serverUrl}</p>
|
||||
<p className="setup-subtitle">Sign in to {serverUrl.includes('127.0.0.1') ? 'this device' : serverUrl}</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="login-username">Username</label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import LogoMark from './LogoMark';
|
||||
import TrackerScreen from './TrackerScreen';
|
||||
import { getSession, logout, type SessionUser } from './api';
|
||||
|
||||
interface Props {
|
||||
/** API base — the remote server, or the on-device server in local mode. */
|
||||
baseUrl: string;
|
||||
/** Session is invalid — bounce back to the login screen. */
|
||||
onUnauthenticated: () => void;
|
||||
/** Signed out — return to first-run/login. */
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'tracker' | 'calendar' | 'insights' | 'account';
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'tracker',
|
||||
label: 'Tracker',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Calendar',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="17" rx="2" /><path d="M3 9h18M8 2v4M16 2v4" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'insights',
|
||||
label: 'Insights',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v18h18" /><path d="M7 15l4-5 3 3 4-6" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: 'Account',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></svg>,
|
||||
},
|
||||
];
|
||||
|
||||
function Placeholder({ title, note }: { title: string; note: string }) {
|
||||
return (
|
||||
<div className="placeholder">
|
||||
<h2>{title}</h2>
|
||||
<p>{note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MainApp({ baseUrl, onUnauthenticated, onSignOut }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('tracker');
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSession(baseUrl).then(res => {
|
||||
if (res.status === 401) { onUnauthenticated(); return; }
|
||||
const u = res.data?.user ?? (res.data as SessionUser | undefined);
|
||||
if (u?.username) setUser(u);
|
||||
});
|
||||
}, [baseUrl, onUnauthenticated]);
|
||||
|
||||
async function handleSignOut() {
|
||||
setSigningOut(true);
|
||||
await logout(baseUrl);
|
||||
onSignOut();
|
||||
}
|
||||
|
||||
const initial = (user?.username || '?').charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-topbar">
|
||||
<LogoMark size={30} />
|
||||
<span className="app-topbar__word">Bill <b>Tracker</b></span>
|
||||
<span className="app-topbar__spacer" />
|
||||
<button className="avatar" onClick={() => setTab('account')} aria-label="Account">{initial}</button>
|
||||
</header>
|
||||
|
||||
<main className="app-main" key={tab}>
|
||||
{tab === 'tracker' && <TrackerScreen baseUrl={baseUrl} onUnauthenticated={onUnauthenticated} />}
|
||||
{tab === 'calendar' && <Placeholder title="Calendar" note="A month-at-a-glance view of when bills are due is coming here next." />}
|
||||
{tab === 'insights' && <Placeholder title="Insights" note="Spending trends and this-vs-last-month comparisons are coming here next." />}
|
||||
{tab === 'account' && (
|
||||
<div className="app-state">
|
||||
<div className="avatar" style={{ width: 56, height: 56, fontSize: '1.5rem' }}>{initial}</div>
|
||||
<h2>{user?.username ?? 'Signed in'}</h2>
|
||||
<p>{user?.role === 'admin' ? 'Administrator' : 'Signed in to this device'}</p>
|
||||
<button className="btn-secondary" style={{ maxWidth: 220 }} onClick={handleSignOut} disabled={signingOut}>
|
||||
{signingOut ? 'Signing out…' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<nav className="tabbar">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={'tab' + (tab === t.id ? ' is-active' : '')}
|
||||
onClick={() => setTab(t.id)}
|
||||
aria-current={tab === t.id ? 'page' : undefined}
|
||||
>
|
||||
{t.icon}
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getTracker, toggleBillPaid, type TrackerData, type Bill } from './api';
|
||||
|
||||
interface Props {
|
||||
baseUrl: string;
|
||||
/** Called when the API reports the session is no longer valid (401). */
|
||||
onUnauthenticated: () => void;
|
||||
}
|
||||
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
/** "2026-07-01" → "Jul 1" without tripping over timezones. */
|
||||
function shortDate(iso: string): string {
|
||||
const [, m, d] = iso.split('-').map(Number);
|
||||
return `${MONTHS_SHORT[(m || 1) - 1]} ${d}`;
|
||||
}
|
||||
|
||||
function billTone(b: Bill): 'paid' | 'overdue' | 'upcoming' {
|
||||
if (b.is_settled || b.status === 'paid') return 'paid';
|
||||
if (b.status === 'missed') return 'overdue';
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
function statusLabel(b: Bill): string {
|
||||
const tone = billTone(b);
|
||||
if (tone === 'paid') return 'Paid';
|
||||
if (tone === 'overdue') return 'Overdue';
|
||||
return 'Upcoming';
|
||||
}
|
||||
|
||||
const CheckIcon = (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function TrackerScreen({ baseUrl, onUnauthenticated }: Props) {
|
||||
const [ym, setYm] = useState<{ year: number; month: number } | null>(null);
|
||||
const [data, setData] = useState<TrackerData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (target?: { year: number; month: number }) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const res = await getTracker(baseUrl, target?.year, target?.month);
|
||||
if (res.status === 401) {
|
||||
onUnauthenticated();
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.error || 'Could not load your bills.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setData(res.data);
|
||||
setYm({ year: res.data.year, month: res.data.month });
|
||||
setLoading(false);
|
||||
},
|
||||
[baseUrl, onUnauthenticated],
|
||||
);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function nav(delta: number) {
|
||||
if (!ym) return;
|
||||
let month = ym.month + delta;
|
||||
let year = ym.year;
|
||||
if (month < 1) { month = 12; year -= 1; }
|
||||
if (month > 12) { month = 1; year += 1; }
|
||||
load({ year, month });
|
||||
}
|
||||
|
||||
async function toggle(bill: Bill) {
|
||||
setBusyId(bill.id);
|
||||
const res = await toggleBillPaid(baseUrl, bill.id);
|
||||
setBusyId(null);
|
||||
if (res.status === 401) { onUnauthenticated(); return; }
|
||||
if (res.ok) load(ym ?? undefined);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="app-state">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="app-state">
|
||||
<h2>Couldn't load your bills</h2>
|
||||
<p>{error}</p>
|
||||
<button className="btn-primary" style={{ maxWidth: 200 }} onClick={() => load(ym ?? undefined)}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const d = data!;
|
||||
const rows = d.rows || [];
|
||||
const s = d.summary;
|
||||
const unpaid = rows.length - s.count_paid;
|
||||
const lastDay = new Date(d.year, d.month, 0).getDate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="tracker-kicker">Monthly tracker</div>
|
||||
<h1 className="tracker-title">{MONTHS[d.month - 1]} {d.year}</h1>
|
||||
<div className="tracker-range">{MONTHS_SHORT[d.month - 1]} 1 – {MONTHS_SHORT[d.month - 1]} {lastDay}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
<div className="stat stat--paid">
|
||||
<span className="stat__label">Paid</span>
|
||||
<span className="stat__value">{s.count_paid}</span>
|
||||
</div>
|
||||
<div className={'stat' + (unpaid > 0 ? ' stat--warn' : '')}>
|
||||
<span className="stat__label">Needs action</span>
|
||||
<span className="stat__value">{unpaid}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat__label">Bills</span>
|
||||
<span className="stat__value">{rows.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat__label">Remaining</span>
|
||||
<span className="stat__value">{money(s.total_remaining)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="month-nav">
|
||||
<div className="month-nav__pager">
|
||||
<button onClick={() => nav(-1)} aria-label="Previous month">‹</button>
|
||||
<span>{MONTHS[d.month - 1]} {d.year}</span>
|
||||
<button onClick={() => nav(1)} aria-label="Next month">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="app-state">
|
||||
<h2>No bills this month</h2>
|
||||
<p>When you add bills, they'll show up here with what's paid and what's due.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bill-list">
|
||||
{rows.map(b => {
|
||||
const tone = billTone(b);
|
||||
return (
|
||||
<div key={b.id} className={`bill bill--${tone}`}>
|
||||
<button
|
||||
className="bill__toggle"
|
||||
onClick={() => toggle(b)}
|
||||
disabled={busyId === b.id}
|
||||
aria-pressed={tone === 'paid'}
|
||||
aria-label={tone === 'paid' ? `Mark ${b.name} unpaid` : `Mark ${b.name} paid`}
|
||||
>
|
||||
{CheckIcon}
|
||||
</button>
|
||||
<div className="bill__body">
|
||||
<span className="bill__name">{b.name}</span>
|
||||
<span className="bill__meta">
|
||||
{b.category_name ? `${b.category_name} · ` : ''}due {shortDate(b.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bill__right">
|
||||
<div className="bill__amount">{money(b.expected_amount)}</div>
|
||||
<div className="bill__status">{statusLabel(b)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
src/api.ts
83
src/api.ts
|
|
@ -122,7 +122,86 @@ export async function totpChallenge(
|
|||
);
|
||||
}
|
||||
|
||||
/** GET /api/auth/me — used to re-validate a session (e.g. after biometric unlock). */
|
||||
export function getSession(baseUrl: string): Promise<ApiResult<{ user?: { id: number } }>> {
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** GET /api/auth/me — current session; 401 when not signed in. */
|
||||
export function getSession(baseUrl: string): Promise<ApiResult<{ user?: SessionUser } & Partial<SessionUser>>> {
|
||||
return request(`${baseUrl}/api/auth/me`, { method: 'GET' });
|
||||
}
|
||||
|
||||
// ── Domain: the monthly Tracker (subset of /api/tracker we render) ────────────
|
||||
|
||||
export interface Bill {
|
||||
id: number;
|
||||
name: string;
|
||||
category_name: string | null;
|
||||
due_date: string; // YYYY-MM-DD
|
||||
due_day: number;
|
||||
bucket: string;
|
||||
expected_amount: number;
|
||||
balance: number;
|
||||
total_paid: number;
|
||||
status: string; // 'paid' | 'missed' | 'upcoming' | 'partial' | 'autodraft' | …
|
||||
is_settled: boolean;
|
||||
has_payment: boolean;
|
||||
autopay_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TrackerSummary {
|
||||
total_expected: number;
|
||||
total_paid: number;
|
||||
remaining: number;
|
||||
total_remaining: number;
|
||||
overdue: number;
|
||||
count_paid: number;
|
||||
count_upcoming: number;
|
||||
count_late: number;
|
||||
remaining_label: string;
|
||||
}
|
||||
|
||||
export interface TrackerData {
|
||||
year: number;
|
||||
month: number; // 1–12
|
||||
today: string;
|
||||
summary: TrackerSummary;
|
||||
rows: Bill[];
|
||||
}
|
||||
|
||||
/** Authed mutation: fetch the CSRF token, then echo it as x-csrf-token. */
|
||||
async function mutate<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
|
||||
const csrf = await fetchCsrfToken(baseUrl);
|
||||
return request<T>(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(csrf ? { [CSRF_HEADER]: csrf } : {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/tracker — a month's bills + summary (defaults to the current month). */
|
||||
export function getTracker(baseUrl: string, year?: number, month?: number): Promise<ApiResult<TrackerData>> {
|
||||
const q = year && month ? `?year=${year}&month=${month}` : '';
|
||||
return request<TrackerData>(`${baseUrl}/api/tracker${q}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
/** POST /api/bills/:id/toggle-paid — flip a bill's paid state for the month. */
|
||||
export function toggleBillPaid(baseUrl: string, billId: number): Promise<ApiResult<unknown>> {
|
||||
return mutate(baseUrl, 'POST', `/api/bills/${billId}/toggle-paid`, {});
|
||||
}
|
||||
|
||||
/** POST /api/bills/ — create a bill. */
|
||||
export function createBill(
|
||||
baseUrl: string,
|
||||
bill: { name: string; due_day: number; expected_amount: number },
|
||||
): Promise<ApiResult<Bill>> {
|
||||
return mutate<Bill>(baseUrl, 'POST', '/api/bills/', bill);
|
||||
}
|
||||
|
||||
/** POST /api/auth/logout */
|
||||
export function logout(baseUrl: string): Promise<ApiResult<unknown>> {
|
||||
return mutate(baseUrl, 'POST', '/api/auth/logout', {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
/* Native main-app screens (Tracker, tabs). Token-driven like the shell — see
|
||||
theme.css. Kept separate from index.css (the auth/setup shell) for clarity. */
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Top bar ─────────────────────────────────────────────────────── */
|
||||
.app-topbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding-top: max(var(--space-3), env(safe-area-inset-top));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.app-topbar .logo-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.app-topbar__word {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.app-topbar__word b { color: var(--color-primary); }
|
||||
|
||||
.app-topbar__spacer { flex: 1; }
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Scroll region ───────────────────────────────────────────────── */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: var(--space-4) var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Tracker: month header ───────────────────────────────────────── */
|
||||
.tracker-kicker {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.tracker-title {
|
||||
font-size: 1.9rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.tracker-range {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Stat tiles ──────────────────────────────────────────────────── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.stat {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.stat__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.stat__label::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-faint);
|
||||
}
|
||||
.stat__value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat--paid { border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); }
|
||||
.stat--paid .stat__label { color: var(--color-primary); }
|
||||
.stat--paid .stat__label::before { background: var(--color-primary); }
|
||||
.stat--warn { border-color: color-mix(in srgb, var(--color-warn) 40%, var(--color-border)); }
|
||||
.stat--warn .stat__label { color: var(--color-warn); }
|
||||
.stat--warn .stat__label::before { background: var(--color-warn); }
|
||||
.stat--warn .stat__value { color: var(--color-warn); }
|
||||
|
||||
/* ── Month nav + add ─────────────────────────────────────────────── */
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.month-nav__pager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.month-nav__pager button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.month-nav__pager button:disabled { opacity: 0.35; }
|
||||
.icon-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.icon-btn--primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.icon-btn--primary:active { background: var(--color-primary-active); }
|
||||
.icon-btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-dim);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Bill rows ───────────────────────────────────────────────────── */
|
||||
.bill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.bill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-text-faint);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.bill--paid { border-left-color: var(--color-primary); }
|
||||
.bill--overdue { border-left-color: var(--color-warn); }
|
||||
|
||||
.bill__toggle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: 1.6px solid var(--color-border-strong);
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.bill__toggle svg { width: 16px; height: 16px; }
|
||||
.bill--paid .bill__toggle {
|
||||
background: var(--color-primary-tint);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.bill__toggle:disabled { opacity: 0.5; }
|
||||
|
||||
.bill__body { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.bill__name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bill__meta { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.bill__right { margin-left: auto; text-align: right; flex: 0 0 auto; }
|
||||
.bill__amount {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bill__status {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.bill--paid .bill__status { color: var(--color-primary); }
|
||||
.bill--overdue .bill__status { color: var(--color-warn); }
|
||||
.bill--upcoming .bill__status { color: var(--color-text-faint); }
|
||||
|
||||
/* ── States ──────────────────────────────────────────────────────── */
|
||||
.app-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.app-state h2 { font-size: 1.05rem; font-weight: 700; color: var(--color-text); }
|
||||
.app-state p { font-size: 0.875rem; max-width: 34ch; }
|
||||
|
||||
.placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.placeholder__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.placeholder h2 { font-size: 1.1rem; font-weight: 700; color: var(--color-text); }
|
||||
.placeholder p { font-size: 0.875rem; max-width: 30ch; }
|
||||
|
||||
/* ── Bottom tab bar ──────────────────────────────────────────────── */
|
||||
.tabbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: stretch;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0.55rem 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.tab svg { width: 22px; height: 22px; }
|
||||
.tab.is-active { color: var(--color-primary); }
|
||||
.tab:focus-visible { outline: 2px solid var(--color-ring); outline-offset: -2px; }
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import './app.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
|
|
|||
Loading…
Reference in New Issue