Polish mobile and web experience
This commit is contained in:
@@ -14,7 +14,10 @@ export function FilterChip({ label, active, onPress }: FilterChipProps) {
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: Boolean(active) }}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.chip,
|
||||
@@ -39,7 +42,7 @@ export function FilterChip({ label, active, onPress }: FilterChipProps) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
height: 32,
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.pill,
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { RefreshControl, type RefreshControlProps } from "react-native";
|
||||
|
||||
type PullToRefreshProps = Omit<RefreshControlProps, "onRefresh" | "refreshing"> & {
|
||||
onRefresh: () => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps the native refresh indicator tied to an actual pull gesture.
|
||||
* Query `isRefetching` also covers background polling and invalidations, which
|
||||
* can repeatedly move an offscreen iOS scroll view when used as `refreshing`.
|
||||
*/
|
||||
export function PullToRefresh({ onRefresh, ...props }: PullToRefreshProps) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (refreshing) return;
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [onRefresh, refreshing]);
|
||||
|
||||
return (
|
||||
<RefreshControl
|
||||
{...props}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void handleRefresh()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,15 @@ import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-na
|
||||
import Swipeable, {
|
||||
type SwipeableMethods,
|
||||
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { resolveActionForeground } from "@/lib/action-contrast";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
|
||||
@@ -29,6 +35,38 @@ type SwipeableRowProps = {
|
||||
contentStyle?: PressableProps["style"];
|
||||
};
|
||||
|
||||
type SwipeActionsProps = {
|
||||
actions: SwipeAction[];
|
||||
progress: SharedValue<number>;
|
||||
onActionPress: (action: SwipeAction) => void;
|
||||
};
|
||||
|
||||
function SwipeActions({ actions, progress, onActionPress }: SwipeActionsProps) {
|
||||
const styles = useThemedStyles(createSwipeableRowStyles);
|
||||
const revealStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(progress.value, [0, 0.02, 0.15], [0, 0, 1], "clamp"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.actions, revealStyle]}>
|
||||
{actions.map((action) => {
|
||||
const foreground = resolveActionForeground(action.backgroundColor, action.color);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={action.key}
|
||||
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||
onPress={() => onActionPress(action)}
|
||||
>
|
||||
<Ionicons name={action.icon} size={20} color={foreground} />
|
||||
<Text style={[styles.actionLabel, { color: foreground }]}>{action.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwipeableRow({
|
||||
children,
|
||||
actions,
|
||||
@@ -92,25 +130,20 @@ export function SwipeableRow({
|
||||
);
|
||||
}
|
||||
|
||||
function renderRightActions() {
|
||||
function handleActionPress(action: SwipeAction) {
|
||||
suppressContentPress();
|
||||
swipeRef.current?.close();
|
||||
rowOpenRef.current = false;
|
||||
action.onPress();
|
||||
}
|
||||
|
||||
function renderRightActions(progress: SharedValue<number>) {
|
||||
return (
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => (
|
||||
<Pressable
|
||||
key={action.key}
|
||||
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||
onPress={() => {
|
||||
suppressContentPress();
|
||||
swipeRef.current?.close();
|
||||
rowOpenRef.current = false;
|
||||
action.onPress();
|
||||
}}
|
||||
>
|
||||
<Ionicons name={action.icon} size={20} color={action.color} />
|
||||
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<SwipeActions
|
||||
actions={actions}
|
||||
progress={progress}
|
||||
onActionPress={handleActionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +154,7 @@ export function SwipeableRow({
|
||||
return (
|
||||
<Swipeable
|
||||
ref={swipeRef}
|
||||
containerStyle={styles.container}
|
||||
renderRightActions={renderRightActions}
|
||||
overshootRight={false}
|
||||
onSwipeableOpenStartDrag={suppressContentPress}
|
||||
@@ -141,11 +175,15 @@ export function SwipeableRow({
|
||||
|
||||
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: radii.lg,
|
||||
overflow: "hidden",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radii.lg,
|
||||
overflow: "hidden",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -17,7 +17,7 @@ import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { isRequiredString } from "@/lib/form-validation";
|
||||
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type BusinessFormValues = {
|
||||
@@ -78,6 +78,7 @@ export function BusinessForm({
|
||||
|
||||
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
const switchProps = {
|
||||
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
||||
@@ -154,6 +155,7 @@ export function BusinessForm({
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
markSubmitted();
|
||||
if (!canSave) return;
|
||||
|
||||
const payload = buildPayload();
|
||||
@@ -203,8 +205,9 @@ export function BusinessForm({
|
||||
label="Name"
|
||||
value={values.name}
|
||||
onChangeText={(v) => patch("name", v)}
|
||||
onBlur={() => touch("name")}
|
||||
required
|
||||
error={nameError}
|
||||
error={visible("name") ? nameError : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Nickname"
|
||||
@@ -281,7 +284,7 @@ export function BusinessForm({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
|
||||
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
|
||||
@@ -15,7 +15,11 @@ import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { isRequiredString, parseNonNegativeNumber } from "@/lib/form-validation";
|
||||
import {
|
||||
isRequiredString,
|
||||
parseNonNegativeNumber,
|
||||
useFieldVisibility,
|
||||
} from "@/lib/form-validation";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export type ClientFormValues = {
|
||||
@@ -71,6 +75,7 @@ export function ClientForm({
|
||||
|
||||
const [values, setValues] = useState<ClientFormValues>(emptyValues);
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
const client = clientQuery.data;
|
||||
@@ -125,6 +130,7 @@ export function ClientForm({
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
markSubmitted();
|
||||
if (!canSave) return;
|
||||
|
||||
const rate = values.defaultHourlyRate.trim()
|
||||
@@ -199,8 +205,9 @@ export function ClientForm({
|
||||
label="Name"
|
||||
value={values.name}
|
||||
onChangeText={(v) => patch("name", v)}
|
||||
onBlur={() => touch("name")}
|
||||
required
|
||||
error={nameError}
|
||||
error={visible("name") ? nameError : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
@@ -247,9 +254,10 @@ export function ClientForm({
|
||||
label="Default hourly rate"
|
||||
value={values.defaultHourlyRate}
|
||||
onChangeText={(v) => patch("defaultHourlyRate", v)}
|
||||
onBlur={() => touch("defaultHourlyRate")}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="Optional"
|
||||
error={rateError}
|
||||
error={visible("defaultHourlyRate") ? rateError : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Currency"
|
||||
@@ -260,7 +268,7 @@ export function ClientForm({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
|
||||
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
|
||||
@@ -14,14 +14,18 @@ type InvoiceSetupFormProps = {
|
||||
onBusinessIdChange: (value: string) => void;
|
||||
businessOptions: SelectOption[];
|
||||
businessError?: string;
|
||||
onBusinessBlur?: () => void;
|
||||
businessReadOnly?: boolean;
|
||||
clientId: string;
|
||||
onClientIdChange: (value: string) => void;
|
||||
clientOptions: SelectOption[];
|
||||
clientError?: string;
|
||||
onClientBlur?: () => void;
|
||||
clientReadOnly?: boolean;
|
||||
invoiceNumber: string;
|
||||
onInvoiceNumberChange?: (value: string) => void;
|
||||
invoiceNumberError?: string;
|
||||
onInvoiceNumberBlur?: () => void;
|
||||
invoiceNumberReadOnly?: boolean;
|
||||
issueDate: Date;
|
||||
onIssueDateChange?: (date: Date) => void;
|
||||
@@ -30,6 +34,8 @@ type InvoiceSetupFormProps = {
|
||||
onDueDateChange: (date: Date) => void;
|
||||
taxRate: string;
|
||||
onTaxRateChange?: (value: string) => void;
|
||||
taxRateError?: string;
|
||||
onTaxRateBlur?: () => void;
|
||||
taxRateReadOnly?: boolean;
|
||||
notes: string;
|
||||
onNotesChange: (value: string) => void;
|
||||
@@ -43,14 +49,18 @@ export function InvoiceSetupForm({
|
||||
onBusinessIdChange,
|
||||
businessOptions,
|
||||
businessError,
|
||||
onBusinessBlur,
|
||||
businessReadOnly = false,
|
||||
clientId,
|
||||
onClientIdChange,
|
||||
clientOptions,
|
||||
clientError,
|
||||
onClientBlur,
|
||||
clientReadOnly = false,
|
||||
invoiceNumber,
|
||||
onInvoiceNumberChange,
|
||||
invoiceNumberError,
|
||||
onInvoiceNumberBlur,
|
||||
invoiceNumberReadOnly = false,
|
||||
issueDate,
|
||||
onIssueDateChange,
|
||||
@@ -59,6 +69,8 @@ export function InvoiceSetupForm({
|
||||
onDueDateChange,
|
||||
taxRate,
|
||||
onTaxRateChange,
|
||||
taxRateError,
|
||||
onTaxRateBlur,
|
||||
taxRateReadOnly = false,
|
||||
notes,
|
||||
onNotesChange,
|
||||
@@ -84,6 +96,7 @@ export function InvoiceSetupForm({
|
||||
error={businessError}
|
||||
disabled={businessReadOnly}
|
||||
onValueChange={onBusinessIdChange}
|
||||
onBlur={onBusinessBlur}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -101,6 +114,7 @@ export function InvoiceSetupForm({
|
||||
error={clientError}
|
||||
disabled={clientReadOnly}
|
||||
onValueChange={onClientIdChange}
|
||||
onBlur={onClientBlur}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -118,8 +132,10 @@ export function InvoiceSetupForm({
|
||||
label="Invoice number"
|
||||
value={invoiceNumber}
|
||||
onChangeText={onInvoiceNumberChange}
|
||||
onBlur={onInvoiceNumberBlur}
|
||||
autoCapitalize="characters"
|
||||
required
|
||||
error={invoiceNumberError}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -160,7 +176,9 @@ export function InvoiceSetupForm({
|
||||
label="Tax rate (%)"
|
||||
value={taxRate}
|
||||
onChangeText={onTaxRateChange}
|
||||
onBlur={onTaxRateBlur}
|
||||
keyboardType="decimal-pad"
|
||||
error={taxRateError}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -13,6 +12,7 @@ import { router } from "expo-router";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||
@@ -20,16 +20,14 @@ 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 { SelectField } from "@/components/ui/SelectField";
|
||||
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 { setLastTimeClockClientId } from "@/lib/time-clock-prefs";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import {
|
||||
endTimeClockLiveActivity,
|
||||
@@ -99,12 +97,8 @@ export function TimeClockPanel({
|
||||
const [agoMinutes, setAgoMinutes] = useState(60);
|
||||
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||
const [clientsExpanded, setClientsExpanded] = useState(false);
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
|
||||
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
|
||||
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false);
|
||||
|
||||
const running = runningQuery.data;
|
||||
const elapsed = useRunningElapsed(running?.startedAt);
|
||||
@@ -124,19 +118,6 @@ export function TimeClockPanel({
|
||||
|
||||
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();
|
||||
@@ -180,18 +161,6 @@ export function TimeClockPanel({
|
||||
},
|
||||
});
|
||||
|
||||
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 ?? "");
|
||||
@@ -209,22 +178,6 @@ export function TimeClockPanel({
|
||||
setRateText((current) => current.trim() || clientRateText(client));
|
||||
}, [clientId, clients, running]);
|
||||
|
||||
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]);
|
||||
|
||||
const selectedClient = clients.find((client) => client.id === clientId);
|
||||
const rateCurrency = selectedClient?.currency ?? "USD";
|
||||
const effectiveRate = resolveEffectiveHourlyRate(
|
||||
@@ -235,21 +188,6 @@ export function TimeClockPanel({
|
||||
? (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);
|
||||
@@ -285,11 +223,14 @@ export function TimeClockPanel({
|
||||
),
|
||||
[entriesQuery.data, todayStart],
|
||||
);
|
||||
const todayHours = useMemo(
|
||||
() => todayEntries.reduce((total, entry) => total + Number(entry.hours ?? 0), 0),
|
||||
[todayEntries],
|
||||
);
|
||||
|
||||
async function persistClientChoice(nextClientId: string, syncState = false) {
|
||||
async function persistClientChoice(nextClientId: string) {
|
||||
if (!activeAccountId || !nextClientId) return;
|
||||
await setLastTimeClockClientId(activeAccountId, nextClientId);
|
||||
if (syncState) setStoredLastClientId(nextClientId);
|
||||
}
|
||||
|
||||
function selectClient(nextClientId: string) {
|
||||
@@ -305,9 +246,6 @@ export function TimeClockPanel({
|
||||
setClientId(nextClientId);
|
||||
setInvoiceId("");
|
||||
setRateText(clientRateText(client));
|
||||
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
|
||||
setClientsExpanded(true);
|
||||
}
|
||||
if (nextClientId) {
|
||||
void persistClientChoice(nextClientId);
|
||||
}
|
||||
@@ -420,63 +358,83 @@ export function TimeClockPanel({
|
||||
.join(" · ");
|
||||
|
||||
|
||||
function renderClientChip(client: (typeof clients)[number]) {
|
||||
return (
|
||||
<FilterChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={clientId === client.id}
|
||||
onPress={() => 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();
|
||||
}}
|
||||
<PullToRefresh
|
||||
onRefresh={() =>
|
||||
Promise.all([
|
||||
runningQuery.refetch(),
|
||||
clientsQuery.refetch(),
|
||||
billableQuery.refetch(),
|
||||
entriesQuery.refetch(),
|
||||
])
|
||||
}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{running || !compact ? (
|
||||
{running ? (
|
||||
<GlassSurface style={running ? styles.runningCard : undefined}>
|
||||
<View style={[styles.hero, running && styles.heroRunning]}>
|
||||
{running ? (
|
||||
<>
|
||||
<View style={styles.heroHeader}>
|
||||
<View style={styles.pulseDot} />
|
||||
<Text style={styles.heroLabelRunning}>Timer running</Text>
|
||||
</View>
|
||||
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
||||
<Text style={styles.runningTitle}>{runningTitle}</Text>
|
||||
{runningMeta ? (
|
||||
<Text style={styles.runningMeta}>{runningMeta}</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.idleHint}>
|
||||
Start the timer anytime — add client, invoice, and details later.
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.heroHeader}>
|
||||
<View style={styles.pulseDot} />
|
||||
<Text style={styles.heroLabelRunning}>In progress</Text>
|
||||
</View>
|
||||
<Text selectable style={styles.timerValue}>
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</Text>
|
||||
<Text selectable style={styles.runningTitle}>{runningTitle}</Text>
|
||||
{runningMeta ? (
|
||||
<Text selectable style={styles.runningMeta}>{runningMeta}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<GlassSurface style={styles.setupCard}>
|
||||
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
|
||||
{!running ? (
|
||||
<View style={styles.idleIntro}>
|
||||
<Text style={styles.idleEyebrow}>Ready to start</Text>
|
||||
<Text style={styles.idleTitle}>What are you working on?</Text>
|
||||
<Text style={styles.idleCopy}>
|
||||
Add what you know now. You can update the rest while the timer runs.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{running ? (
|
||||
<View style={styles.formSection}>
|
||||
<>
|
||||
<Button
|
||||
title={clockOut.isPending ? "Stopping…" : "Stop & save entry"}
|
||||
variant="danger"
|
||||
leftIcon="stop"
|
||||
loading={clockOut.isPending}
|
||||
onPress={handleClockOut}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: optionsExpanded }}
|
||||
onPress={() => setOptionsExpanded((open) => !open)}
|
||||
style={({ pressed }) => [
|
||||
styles.optionsToggle,
|
||||
pressed && styles.optionsTogglePressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionsToggleText}>
|
||||
<Text style={styles.optionsToggleLabel}>Edit timer details</Text>
|
||||
<Text style={styles.optionsToggleSummary}>
|
||||
Started {formatDateTime(runningStartedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.optionsChevron}>{optionsExpanded ? "−" : "+"}</Text>
|
||||
</Pressable>
|
||||
|
||||
{optionsExpanded ? <View style={styles.formSection}>
|
||||
<Input
|
||||
label="What are you working on?"
|
||||
value={description}
|
||||
@@ -544,79 +502,49 @@ export function TimeClockPanel({
|
||||
}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Button
|
||||
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
|
||||
variant="danger"
|
||||
loading={clockOut.isPending}
|
||||
onPress={handleClockOut}
|
||||
/>
|
||||
</View>
|
||||
</View> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="What are you working on?"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
returnKeyType="done"
|
||||
style={[styles.titleField, { color: colors.foreground }]}
|
||||
/>
|
||||
<View style={styles.idleFields}>
|
||||
<Input
|
||||
label="Description"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="e.g. Client kickoff…"
|
||||
returnKeyType="done"
|
||||
style={styles.titleField}
|
||||
containerStyle={styles.timerField}
|
||||
/>
|
||||
|
||||
<View style={styles.setupSection}>
|
||||
<Text style={styles.sectionLabel}>Client</Text>
|
||||
{clients.length === 0 ? (
|
||||
<Text style={styles.emptyClients}>
|
||||
No clients yet — you can still start the timer and assign a client later.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip
|
||||
label="No client"
|
||||
active={!clientId}
|
||||
onPress={() => selectClient("")}
|
||||
/>
|
||||
{featuredClients.map((client) => renderClientChip(client))}
|
||||
{moreClients.length > 0 ? (
|
||||
<FilterChip
|
||||
label={clientsExpanded ? "Show less" : "Show more"}
|
||||
active={clientsExpanded}
|
||||
onPress={() => setClientsExpanded((open) => !open)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{clientsExpanded && moreClients.length > 0 ? (
|
||||
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
|
||||
{moreClients.map((client) => renderClientChip(client))}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<SelectField
|
||||
label="Client"
|
||||
placeholder="Select a client"
|
||||
value={clientId}
|
||||
options={[
|
||||
{ label: "No client", value: "" },
|
||||
...clients.map((client) => ({ label: client.name, value: client.id })),
|
||||
]}
|
||||
onValueChange={selectClient}
|
||||
containerStyle={styles.timerField}
|
||||
/>
|
||||
|
||||
<View style={styles.setupSection}>
|
||||
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
|
||||
{!clientId ? (
|
||||
<Text style={styles.emptyClients}>
|
||||
No invoice for now. Add a client and invoice later if this becomes billable.
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
|
||||
{billableInvoices.map((invoice) => {
|
||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||
return (
|
||||
<FilterChip
|
||||
key={invoice.id}
|
||||
label={label}
|
||||
active={invoiceId === invoice.id}
|
||||
onPress={() => setInvoiceId(invoice.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<SelectField
|
||||
label="Invoice (optional)"
|
||||
placeholder={clientId ? "Select an invoice" : "Choose a client first"}
|
||||
value={clientId ? invoiceId : "__client_required__"}
|
||||
disabled={!clientId}
|
||||
options={[
|
||||
{ label: "Entry only — no invoice", value: "" },
|
||||
...billableInvoices.map((invoice) => ({
|
||||
label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`,
|
||||
value: invoice.id,
|
||||
})),
|
||||
]}
|
||||
onValueChange={selectInvoice}
|
||||
containerStyle={styles.timerField}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.setupSection}>
|
||||
<Pressable
|
||||
@@ -736,9 +664,8 @@ export function TimeClockPanel({
|
||||
)}
|
||||
</GlassSurface>
|
||||
|
||||
{todayEntries.length > 0 ? (
|
||||
<Card title="Today's entries">
|
||||
{todayEntries.map((entry) => {
|
||||
<Card title={`Today · ${todayHours.toFixed(2)}h`}>
|
||||
{todayEntries.length > 0 ? todayEntries.map((entry) => {
|
||||
const invoiceLabel = entry.invoice
|
||||
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
: null;
|
||||
@@ -783,9 +710,20 @@ export function TimeClockPanel({
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
) : null}
|
||||
}) : (
|
||||
<View style={styles.todayEmpty}>
|
||||
<Text style={styles.todayEmptyTitle}>No time logged yet</Text>
|
||||
<Text style={styles.todayEmptyCopy}>
|
||||
Start your first timer or open history to add an entry manually.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
title="View time history"
|
||||
variant="secondary"
|
||||
onPress={() => router.push("/(app)/more/time-entries")}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<TimeEntryEditSheet
|
||||
entryId={editEntryId}
|
||||
@@ -857,25 +795,45 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
},
|
||||
setupCard: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
idleIntro: {
|
||||
gap: spacing.xs,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
idleEyebrow: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.mutedForeground,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
idleTitle: {
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
idleCopy: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
formSection: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
titleField: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
minHeight: 48,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
idleFields: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
timerField: {
|
||||
gap: 6,
|
||||
},
|
||||
setupSection: {
|
||||
gap: spacing.sm,
|
||||
paddingTop: spacing.lg,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
@@ -892,9 +850,6 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
moreClientsWrap: {
|
||||
paddingTop: spacing.xs,
|
||||
},
|
||||
emptyClients: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
@@ -996,4 +951,23 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
todayEmpty: {
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
todayEmptyTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
},
|
||||
todayEmptyCopy: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,7 +62,12 @@ export function DateTimeField({
|
||||
<View style={styles.wrapper}>
|
||||
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={`${label}, ${
|
||||
mode === "date" ? formatDate(value) : formatDateTime(value)
|
||||
}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: open }}
|
||||
onPress={openPicker}
|
||||
style={({ pressed }) => [
|
||||
styles.trigger,
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextInputProps,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
@@ -17,6 +19,7 @@ type InputProps = TextInputProps & {
|
||||
leftIcon?: keyof typeof Ionicons.glyphMap;
|
||||
labelAccessory?: React.ReactNode;
|
||||
hint?: string;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export function Input({
|
||||
@@ -26,13 +29,14 @@ export function Input({
|
||||
leftIcon,
|
||||
labelAccessory,
|
||||
hint,
|
||||
containerStyle,
|
||||
style,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.wrapper, containerStyle]}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||
{label}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
@@ -26,6 +28,8 @@ type SelectFieldProps = {
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export function SelectField({
|
||||
@@ -37,19 +41,29 @@ export function SelectField({
|
||||
required,
|
||||
error,
|
||||
onValueChange,
|
||||
onBlur,
|
||||
containerStyle,
|
||||
}: SelectFieldProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
onBlur?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.wrapper, containerStyle]}>
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||
{label}
|
||||
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={`${label}, ${selected?.label ?? placeholder}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: Boolean(disabled), expanded: open }}
|
||||
disabled={disabled}
|
||||
onPress={() => setOpen(true)}
|
||||
style={({ pressed }) => [
|
||||
@@ -77,18 +91,18 @@ export function SelectField({
|
||||
|
||||
<Modal
|
||||
animationType="slide"
|
||||
onRequestClose={() => setOpen(false)}
|
||||
onRequestClose={close}
|
||||
transparent
|
||||
visible={open}
|
||||
>
|
||||
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||
<Pressable style={styles.backdrop} onPress={close}>
|
||||
<Pressable
|
||||
style={[styles.sheet, { backgroundColor: colors.background }]}
|
||||
onPress={(event) => event.stopPropagation()}
|
||||
>
|
||||
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
|
||||
<Pressable accessibilityRole="button" onPress={close}>
|
||||
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -101,7 +115,7 @@ export function SelectField({
|
||||
accessibilityRole="button"
|
||||
onPress={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
close();
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.option,
|
||||
|
||||
Reference in New Issue
Block a user