Files
beenvoice-app/components/time-clock/TimeClockPanel.tsx
T
soconnor 06bc91ac13 Redesign mobile time clock, add shortcuts, and improve account management.
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable
client picker with last-client defaults, account refresh/remove, and softer
session handling on unauthorized API responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:17 -04:00

942 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Alert,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import {
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
compact?: boolean;
header?: ReactNode;
};
type ClientRow = {
id: string;
name: string;
defaultHourlyRate: number | null;
currency?: string;
};
type StartMode = "now" | "at" | "ago";
const AGO_PRESETS = [
{ label: "15m", minutes: 15 },
{ label: "30m", minutes: 30 },
{ label: "1h", minutes: 60 },
{ label: "2h", minutes: 120 },
{ label: "4h", minutes: 240 },
] as const;
function clientRateText(client: ClientRow | undefined): string {
return client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "";
}
export function TimeClockPanel({
defaultClientId = "",
defaultInvoiceId = "",
compact = false,
header,
}: TimeClockPanelProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createTimeClockStyles);
const { activeAccountId } = useAccounts();
const utils = api.useUtils();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const clientsQuery = api.clients.getAll.useQuery();
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now");
const [agoMinutes, setAgoMinutes] = useState(60);
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false);
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId));
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
const clients = clientsQuery.data ?? [];
const activeClientId = running?.clientId ?? clientId;
const billableQuery = api.invoices.getBillable.useQuery(
activeClientId ? { clientId: activeClientId } : undefined,
);
const billableInvoices = billableQuery.data ?? [];
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const entriesQuery = api.timeEntries.getAll.useQuery();
const recentClientIds = useMemo(() => {
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of entriesQuery.data ?? []) {
if (entry.clientId && !seen.has(entry.clientId)) {
seen.add(entry.clientId);
ids.push(entry.clientId);
if (ids.length >= 2) break;
}
}
return ids;
}, [entriesQuery.data]);
const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => {
await utils.timeEntries.getRunning.invalidate();
},
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.invoices.getBillable.invalidate(),
]);
},
onError: (err) => {
Alert.alert("Could not update timer", err.message);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
if (running?.clientId && activeAccountId) {
await setLastTimeClockClientId(activeAccountId, running.clientId);
}
const message = describeClockOutOutcome({
outcome: data.outcome,
hours: data.hours,
rate: data.rate,
invoice: data.invoice,
});
Alert.alert(
data.outcome === "linked_to_invoice" ? "Time logged" : "Timer stopped",
message,
);
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
},
});
useEffect(() => {
if (!activeAccountId) {
setPrefsLoaded(true);
return;
}
setPrefsLoaded(false);
void getLastTimeClockClientId(activeAccountId).then((id) => {
setStoredLastClientId(id);
setPrefsLoaded(true);
});
}, [activeAccountId]);
useEffect(() => {
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description?.trim() ?? "");
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
useEffect(() => {
if (!clientId || running || clients.length === 0) return;
const client = clients.find((c) => c.id === clientId);
if (!client?.defaultHourlyRate) return;
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
useEffect(() => {
if (running || defaultClientId || initialClientResolved) return;
if (!prefsLoaded || clients.length === 0) return;
const preferredId =
storedLastClientId && clients.some((client) => client.id === storedLastClientId)
? storedLastClientId
: recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null;
if (preferredId) {
const client = clients.find((c) => c.id === preferredId);
setClientId(preferredId);
setRateText(clientRateText(client));
}
setInitialClientResolved(true);
}, [
clients,
defaultClientId,
initialClientResolved,
prefsLoaded,
recentClientIds,
running,
storedLastClientId,
]);
useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
const ids: string[] = [];
const add = (id: string | null | undefined) => {
if (!id || ids.includes(id)) return;
if (!clients.some((client) => client.id === id)) return;
ids.push(id);
};
add(storedLastClientId);
for (const id of recentClientIds) add(id);
setFeaturedClientIds(ids.slice(0, 1));
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity({ ...running, description }, seconds);
};
sync();
const interval = setInterval(sync, 15_000);
return () => clearInterval(interval);
}, [running, description]);
const selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate(
rateText,
selectedClient?.defaultHourlyRate,
);
const displayRate = running
? (running.rate ?? effectiveRate ?? 0)
: (effectiveRate ?? 0);
const featuredClients = useMemo(
() =>
featuredClientIds
.map((id) => clients.find((client) => client.id === id))
.filter((client) => client != null),
[clients, featuredClientIds],
);
const moreClients = useMemo(() => {
const featuredIds = new Set(featuredClientIds);
return clients
.filter((client) => !featuredIds.has(client.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [clients, featuredClientIds]);
const resolvedStartAt = useMemo(() => {
if (startMode === "now") return new Date();
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
return startedAt;
}, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => {
const next: { clientId?: string; rate?: string; start?: string } = {};
if (!clientId) next.clientId = "Choose a client to start";
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
if (startMode === "ago" && agoMinutes <= 0) {
next.start = "Enter how long ago you started";
}
return next;
}, [agoMinutes, clientId, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0;
const optionsSummary = useMemo(() => {
const rate =
effectiveRate ??
(selectedClient?.defaultHourlyRate != null ? selectedClient.defaultHourlyRate : null);
const rateLabel = rate != null ? `${formatCurrency(rate, rateCurrency)}/hr` : "No rate";
const startLabel = startMode === "now" ? "Starting now" : formatDateTime(resolvedStartAt);
return `${rateLabel} · ${startLabel}`;
}, [effectiveRate, rateCurrency, resolvedStartAt, selectedClient?.defaultHourlyRate, startMode]);
const todayEntries = useMemo(
() =>
(entriesQuery.data ?? []).filter(
(entry) => entry.endedAt && new Date(entry.startedAt) >= todayStart,
),
[entriesQuery.data, todayStart],
);
async function persistClientChoice(nextClientId: string, syncState = false) {
if (!activeAccountId || !nextClientId) return;
await setLastTimeClockClientId(activeAccountId, nextClientId);
if (syncState) setStoredLastClientId(nextClientId);
}
function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId);
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (!featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
void persistClientChoice(nextClientId);
}
function selectStartMode(mode: StartMode) {
setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true);
if (mode === "now") {
setStartedAt(new Date());
return;
}
if (mode === "ago") {
setStartedAt(startedAtFromMinutesAgo(agoMinutes));
return;
}
setStartedAt((current) =>
Math.abs(Date.now() - current.getTime()) < 60_000 ? current : new Date(),
);
}
function selectAgoPreset(minutes: number) {
setStartMode("ago");
setAgoMinutes(minutes);
setAgoMinutesText(String(minutes));
setStartedAt(startedAtFromMinutesAgo(minutes));
}
function handleAgoMinutesChange(text: string) {
setAgoMinutesText(text);
const parsed = Number(text);
if (!Number.isNaN(parsed) && parsed > 0) {
setAgoMinutes(parsed);
setStartedAt(startedAtFromMinutesAgo(parsed));
}
}
async function handleClockIn() {
if (!canClockIn) {
if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
return;
}
try {
const backdated =
startMode === "now" ? undefined : resolvedStartAt;
await clockIn.mutateAsync({
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: effectiveRate ?? undefined,
startedAt: backdated,
});
await persistClientChoice(clientId);
setStartMode("now");
setStartedAt(new Date());
setAgoMinutes(60);
setAgoMinutesText("60");
} catch (err) {
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleClockOut() {
try {
await clockOut.mutateAsync({
description: description.trim() ? description.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleRunningClientChange(nextClientId: string) {
if (!running) return;
setClientId(nextClientId);
setInvoiceId("");
try {
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
const client = clients.find((c) => c.id === nextClientId);
setRateText(clientRateText(client));
await persistClientChoice(nextClientId);
} catch {
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
}
}
async function handleRunningInvoiceChange(nextInvoiceId: string) {
if (!running) return;
const previous = invoiceId;
setInvoiceId(nextInvoiceId);
try {
await updateRunning.mutateAsync({ invoiceId: nextInvoiceId });
} catch {
setInvoiceId(previous);
}
}
if (runningQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading time clock…" />;
}
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null,
displayRate ? `$${displayRate}/hr` : null,
]
.filter(Boolean)
.join(" · ");
const controlsDisabled = Boolean(running && updateRunning.isPending);
function renderClientChip(client: (typeof clients)[number]) {
return (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningClientChange(client.id);
else selectClient(client.id);
}}
/>
);
}
return (
<TabScrollView
style={styles.scroll}
header={header}
refreshControl={
<RefreshControl
refreshing={runningQuery.isRefetching}
onRefresh={() => {
void runningQuery.refetch();
void clientsQuery.refetch();
void billableQuery.refetch();
void entriesQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabel}>Running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)}
{runningMeta ? ` · ${runningMeta}` : ""}
</Text>
</>
) : (
<Text style={styles.idleHint}>
Choose a client and clock in. A draft invoice is created automatically if needed.
</Text>
)}
</View>
</GlassSurface>
) : null}
<GlassSurface style={styles.setupCard}>
<Input
label="Title"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
returnKeyType="done"
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? (
<Text style={styles.emptyClients}>
Add a client first to start tracking time.
</Text>
) : (
<>
<View style={styles.chipWrap}>
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => {
if (controlsDisabled) return;
setClientsExpanded((open) => !open);
}}
/>
) : null}
</View>
{clientsExpanded && moreClients.length > 0 ? (
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
{moreClients.map((client) => renderClientChip(client))}
</View>
) : null}
</>
)}
{clockInErrors.clientId && !running ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null}
</View>
{clientId ? (
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange("");
else setInvoiceId("");
}}
/>
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return (
<FilterChip
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange(invoice.id);
else setInvoiceId(invoice.id);
}}
/>
);
})}
</View>
</View>
) : null}
{!running && clientId ? (
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Rate & start time</Text>
{!optionsExpanded ? (
<Text style={styles.optionsToggleSummary}>{optionsSummary}</Text>
) : null}
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? (
<View style={styles.optionsBody}>
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder={
selectedClient?.defaultHourlyRate != null
? String(selectedClient.defaultHourlyRate)
: "0"
}
error={clockInErrors.rate}
/>
{selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
<Text style={styles.rateHint}>
Defaults to{" "}
{formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
</Text>
) : null}
<Text style={[styles.sectionLabel, styles.sectionLabelInset]}>Start</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Now"
active={startMode === "now"}
onPress={() => selectStartMode("now")}
/>
<FilterChip
label="Pick time"
active={startMode === "at"}
onPress={() => selectStartMode("at")}
/>
<FilterChip
label="Time ago"
active={startMode === "ago"}
onPress={() => selectStartMode("ago")}
/>
</View>
{startMode === "at" ? (
<DateTimeField
label="Started at"
value={startedAt}
maximumDate={new Date()}
onChange={(date) => {
setStartedAt(date);
setStartMode("at");
}}
/>
) : null}
{startMode === "ago" ? (
<View style={styles.agoBlock}>
<View style={styles.chipWrap}>
{AGO_PRESETS.map((preset) => (
<FilterChip
key={preset.label}
label={preset.label}
active={agoMinutes === preset.minutes}
onPress={() => selectAgoPreset(preset.minutes)}
/>
))}
</View>
<View style={styles.agoCustomRow}>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
Started
</Text>
<TextInput
value={agoMinutesText}
onChangeText={handleAgoMinutesChange}
keyboardType="number-pad"
style={[
styles.agoInput,
{ color: colors.foreground, borderColor: colors.border },
]}
/>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
min ago
</Text>
</View>
</View>
) : null}
{clockInErrors.start ? (
<Text style={styles.fieldError}>{clockInErrors.start}</Text>
) : null}
</View>
) : null}
</View>
) : null}
</GlassSurface>
{running ? (
<Button
title="Clock out"
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
) : (
<Button
title="Clock in"
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
onPress={handleClockIn}
/>
)}
{todayEntries.length > 0 ? (
<Card title="Today">
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
</Text>
</View>
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
</>
);
if (!entry.invoice) {
return (
<View key={entry.id} style={styles.entryRow}>
{row}
</View>
);
}
return (
<Pressable
key={entry.id}
accessibilityRole="button"
accessibilityLabel={`View invoice ${invoiceLabel}`}
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
>
{row}
</Pressable>
);
})}
</Card>
) : null}
</TabScrollView>
);
}
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
},
hero: {
padding: spacing.md,
gap: spacing.sm,
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
},
heroLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.4,
},
timerValue: {
fontSize: 52,
lineHeight: 56,
fontFamily: fonts.mono,
color: colors.foreground,
fontVariant: ["tabular-nums"],
},
runningMeta: {
fontSize: 13,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
idleHint: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 20,
},
setupCard: {
padding: spacing.md,
gap: spacing.lg,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
titleInput: {
minHeight: 44,
textAlignVertical: "center",
},
titleInputPlaceholder: {
textAlign: "center",
},
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.6,
},
sectionLabelInset: {
marginTop: spacing.sm,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
moreClientsWrap: {
paddingTop: spacing.xs,
},
emptyClients: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
fieldError: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.destructive,
},
optionsToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
optionsTogglePressed: {
opacity: 0.7,
},
optionsToggleText: {
flex: 1,
gap: 2,
},
optionsToggleLabel: {
fontSize: 14,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
optionsToggleSummary: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
optionsChevron: {
fontSize: 20,
lineHeight: 22,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
},
optionsBody: {
gap: spacing.md,
paddingBottom: spacing.xs,
},
rateHint: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
agoBlock: {
gap: spacing.sm,
},
agoCustomRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
agoCustomLabel: {
fontSize: 14,
fontFamily: fonts.body,
},
agoInput: {
minWidth: 56,
fontSize: 16,
fontFamily: fonts.bodySemiBold,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: 4,
textAlign: "center",
},
entryRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
entryRowPressed: {
opacity: 0.65,
},
entryMeta: {
flex: 1,
gap: 2,
},
entryTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
entrySub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
entryHours: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
});