Polish mobile and web experience

This commit is contained in:
2026-08-17 00:18:55 -04:00
parent 6c74436092
commit 9929d7321d
28 changed files with 835 additions and 688 deletions
+3 -5
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
RefreshControl,
ScrollView,
StyleSheet,
Text,
@@ -15,6 +14,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
@@ -71,8 +71,7 @@ export default function EntitiesScreen() {
const businesses = businessesQuery.data ?? [];
function refresh() {
if (tab === "clients") void clientsQuery.refetch();
else void businessesQuery.refetch();
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
}
function confirmDelete(id: string, name: string) {
@@ -100,8 +99,7 @@ export default function EntitiesScreen() {
/>
}
refreshControl={
<RefreshControl
refreshing={activeQuery.isRefetching}
<PullToRefresh
onRefresh={refresh}
tintColor={colors.primary}
/>
+27 -9
View File
@@ -1,11 +1,12 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { Screen } from "@/components/Screen";
import { StatCard } from "@/components/StatCard";
import { StatusBadge } from "@/components/StatusBadge";
@@ -152,12 +153,8 @@ export default function DashboardScreen() {
<TabScrollView
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
refreshControl={
<RefreshControl
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
onRefresh={() => {
void statsQuery.refetch();
void runningQuery.refetch();
}}
<PullToRefresh
onRefresh={() => Promise.all([statsQuery.refetch(), runningQuery.refetch()])}
tintColor={colors.primary}
/>
}
@@ -221,7 +218,10 @@ export default function DashboardScreen() {
</Card>
{running ? (
<Pressable onPress={() => router.push("/(app)/timer")}>
<Pressable
accessibilityRole="button"
onPress={() => router.push("/(app)/timer")}
>
<GlassSurface style={styles.runningGlass}>
<View style={styles.runningRow}>
<View style={styles.runningDot} />
@@ -299,6 +299,11 @@ export default function DashboardScreen() {
{stats.currentDraft ? (
<GlassSurface style={styles.draftGlass}>
<Pressable
accessible
accessibilityLabel={`Current draft, ${
stats.currentDraft.client?.name ?? "Client"
}, ${formatCurrency(stats.currentDraft.totalAmount)}, ${stats.currentDraft.totalHours.toFixed(1)} hours logged`}
accessibilityRole="button"
style={styles.draftBanner}
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
>
@@ -325,7 +330,12 @@ export default function DashboardScreen() {
<View style={styles.statCell}>
<StatCard label="Overdue" value={String(stats.overdueCount)} />
</View>
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Clients, ${stats.totalClients}`}
style={styles.statCell}
onPress={() => router.push("/(app)/entities")}
>
<StatCard label="Clients" value={String(stats.totalClients)} />
</Pressable>
</View>
@@ -354,6 +364,14 @@ export default function DashboardScreen() {
const status = getInvoiceStatus(invoice);
return (
<Pressable
accessible
accessibilityLabel={`${invoice.invoicePrefix}${invoice.invoiceNumber}, ${
invoice.client?.name ?? "Client"
}, ${formatDate(invoice.issueDate)}, ${formatCurrency(
invoice.totalAmount,
invoice.currency,
)}, ${status}`}
accessibilityRole="button"
key={invoice.id}
style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
+2 -3
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
RefreshControl,
ScrollView,
StyleSheet,
Text,
@@ -15,6 +14,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow";
import { StatusBadge } from "@/components/StatusBadge";
import { TabPage } from "@/components/TabPage";
@@ -121,8 +121,7 @@ export default function InvoicesScreen() {
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
}
refreshControl={
<RefreshControl
refreshing={invoicesQuery.isRefetching}
<PullToRefresh
onRefresh={() => invoicesQuery.refetch()}
tintColor={colors.primary}
/>
+21 -8
View File
@@ -29,6 +29,7 @@ import { formatCurrency } from "@/lib/format";
import {
isRequiredString,
isValidTaxRate,
useFieldVisibility,
validateLineItems,
} from "@/lib/form-validation";
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
@@ -70,6 +71,7 @@ export default function NewInvoiceScreen() {
);
const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null);
const { touch, visible, markSubmitted } = useFieldVisibility();
useEffect(() => {
if (businessId || !businessesQuery.data?.length) return;
@@ -184,10 +186,12 @@ export default function NewInvoiceScreen() {
}
function updateItem(index: number, patch: Partial<EditableLineItem>) {
touch("lineItems");
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
touch("lineItems");
setItems((prev) => [
...prev,
{
@@ -200,10 +204,12 @@ export default function NewInvoiceScreen() {
}
function removeItem(index: number) {
touch("lineItems");
setItems((prev) => prev.filter((_, i) => i !== index));
}
function duplicateItem(index: number) {
touch("lineItems");
setItems((prev) => {
const source = prev[index];
if (!source) return prev;
@@ -213,6 +219,7 @@ export default function NewInvoiceScreen() {
}
function handleCreate() {
markSubmitted();
if (!canCreate) return;
setError(null);
@@ -296,27 +303,31 @@ export default function NewInvoiceScreen() {
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={businessError}
businessError={visible("business") ? businessError : undefined}
onBusinessBlur={() => touch("business")}
clientId={clientId}
onClientIdChange={setClientId}
clientOptions={clientOptions}
clientError={clientError}
clientError={visible("client") ? clientError : undefined}
onClientBlur={() => touch("client")}
invoiceNumber={invoiceNumber}
onInvoiceNumberChange={setInvoiceNumber}
invoiceNumberError={
visible("invoiceNumber") ? invoiceNumberError : undefined
}
onInvoiceNumberBlur={() => touch("invoiceNumber")}
issueDate={issueDate}
onIssueDateChange={setIssueDate}
dueDate={dueDate}
onDueDateChange={setDueDate}
taxRate={taxRate}
onTaxRateChange={setTaxRate}
taxRateError={visible("taxRate") ? taxError : undefined}
onTaxRateBlur={() => touch("taxRate")}
notes={notes}
onNotesChange={setNotes}
/>
)}
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
{invoiceNumberError ? (
<Text style={styles.error}>{invoiceNumberError}</Text>
) : null}
</Card>
) : (
<>
@@ -354,11 +365,13 @@ export default function NewInvoiceScreen() {
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{visible("lineItems") && lineItemsError ? (
<Text selectable style={styles.error}>{lineItemsError}</Text>
) : null}
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
RefreshControl,
ScrollView,
StyleSheet,
Text,
@@ -15,6 +14,7 @@ import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
@@ -110,9 +110,8 @@ export default function ExpensesScreen() {
</View>
}
refreshControl={
<RefreshControl
refreshing={expensesQuery.isRefetching}
onRefresh={() => void expensesQuery.refetch()}
<PullToRefresh
onRefresh={() => expensesQuery.refetch()}
tintColor={colors.primary}
/>
}
+4 -4
View File
@@ -1,8 +1,9 @@
import { RefreshControl, StyleSheet, Text, View } from "react-native";
import { StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
@@ -46,9 +47,8 @@ export default function RecurringScreen() {
/>
}
refreshControl={
<RefreshControl
refreshing={query.isRefetching}
onRefresh={() => void query.refetch()}
<PullToRefresh
onRefresh={() => query.refetch()}
tintColor={colors.primary}
/>
}
+10 -8
View File
@@ -1,8 +1,9 @@
import { RefreshControl, StyleSheet, Text, View } from "react-native";
import { StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { StatCard } from "@/components/StatCard";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
@@ -48,13 +49,14 @@ export default function ReportsScreen() {
<TabScrollView
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
refreshControl={
<RefreshControl
refreshing={statsQuery.isRefetching}
onRefresh={() => {
void statsQuery.refetch();
void expensesQuery.refetch();
void summaryQuery.refetch();
}}
<PullToRefresh
onRefresh={() =>
Promise.all([
statsQuery.refetch(),
expensesQuery.refetch(),
summaryQuery.refetch(),
])
}
tintColor={colors.primary}
/>
}
+4 -4
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { RefreshControl, StyleSheet, Text, View } from "react-native";
import { StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
@@ -74,9 +75,8 @@ export default function TimeEntriesScreen() {
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
}
refreshControl={
<RefreshControl
refreshing={entriesQuery.isRefetching}
onRefresh={() => void entriesQuery.refetch()}
<PullToRefresh
onRefresh={() => entriesQuery.refetch()}
tintColor={colors.primary}
/>
}