Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.

Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:48 -04:00
co-authored by Cursor
parent e17c4c6854
commit 6762a9bff3
60 changed files with 2544 additions and 1091 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { DEFAULT_API_URL } from "@/lib/config";
import { DEFAULT_API_URL, OFFICIAL_SERVER_PLACEHOLDER, invalidServerUrlMessage } from "@/lib/config";
import {
formatServerHost,
isServerConfigValid,
@@ -80,7 +80,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
async function commitSelfHostedUrl() {
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) {
setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
setUrlError(invalidServerUrlMessage());
return;
}
@@ -164,7 +164,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
required
error={urlError ?? undefined}
/>
+2 -1
View File
@@ -8,6 +8,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { hasConfiguredInstanceUrl } from "@/lib/accounts";
import { OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
type CollapsibleServerFieldProps = {
defaultExpanded?: boolean;
@@ -100,7 +101,7 @@ export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleS
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
+3 -2
View File
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { invalidServerUrlMessage, OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
import { normalizeInstanceUrl } from "@/lib/instance-url";
type InstanceUrlFieldProps = {
@@ -30,7 +31,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
const normalized = normalizeInstanceUrl(trimmed);
if (!normalized) {
setError("Enter a valid URL like beenvoice.app or localhost:3000");
setError(invalidServerUrlMessage());
return;
}
@@ -55,7 +56,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
+12
View File
@@ -15,6 +15,10 @@ import {
resolveEffectiveHourlyRate,
} from "@/lib/time-clock";
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import type { ParsedShortcut } from "@/lib/shortcuts";
import { api } from "@/lib/trpc";
@@ -75,6 +79,7 @@ export function ShortcutHandler() {
}
await clockOut.mutateAsync({});
await endTimeClockLiveActivity();
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
@@ -121,6 +126,13 @@ export function ShortcutHandler() {
rate: rate ?? undefined,
});
await utils.timeEntries.getRunning.invalidate();
const running = await utils.timeEntries.getRunning.fetch();
if (running) {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
await syncTimeClockLiveActivity(running, seconds);
}
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
+139
View File
@@ -0,0 +1,139 @@
import { Ionicons } from "@expo/vector-icons";
import * as Linking from "expo-linking";
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { SHORTCUT_URLS } from "@/lib/shortcuts";
const SHORTCUT_ACTIONS = [
{ title: "Clock In", subtitle: "Start timer with your last client", url: SHORTCUT_URLS.clockIn },
{ title: "Clock Out", subtitle: "Stop the running timer", url: SHORTCUT_URLS.clockOut },
{ title: "Open Time Clock", subtitle: "Jump to the timer tab", url: SHORTCUT_URLS.openTimer },
] as const;
export function ShortcutsSetupCard() {
const { colors } = useAppTheme();
if (Platform.OS !== "ios") {
return null;
}
return (
<View style={styles.stack}>
<Text style={[styles.lead, { color: colors.mutedForeground }]}>
beenvoice actions appear when you build a shortcut they are not pre-installed in your
library. After installing a native build (not Expo Go), open the app once, then:
</Text>
<View style={[styles.steps, { borderColor: colors.border, backgroundColor: colors.muted }]}>
<Text style={[styles.step, { color: colors.foreground }]}>
1. Open the Shortcuts app tap + Add Action
</Text>
<Text style={[styles.step, { color: colors.foreground }]}>
2. Search <Text style={styles.emphasis}>beenvoice</Text> (or Clock In / Clock Out)
</Text>
<Text style={[styles.step, { color: colors.foreground }]}>
3. Pick Clock In, Clock Out, or Open Time Clock
</Text>
</View>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
You can also ask Siri: Clock in with beenvoice. Pick a client once on the Timer tab
before your first clock-in shortcut.
</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
If nothing shows up, reinstall from a fresh native build (TestFlight or{" "}
<Text style={styles.emphasis}>bun run ios</Text>). Shortcuts require iOS 18+.
</Text>
<View style={styles.actions}>
{SHORTCUT_ACTIONS.map((action) => (
<Pressable
key={action.title}
accessibilityRole="button"
onPress={() => void Linking.openURL(action.url)}
style={({ pressed }) => [
styles.actionRow,
{
borderColor: colors.border,
backgroundColor: pressed ? colors.muted : "transparent",
},
]}
>
<View style={styles.actionCopy}>
<Text style={[styles.actionTitle, { color: colors.foreground }]}>{action.title}</Text>
<Text style={[styles.actionSubtitle, { color: colors.mutedForeground }]}>
{action.subtitle}
</Text>
</View>
<Ionicons name="open-outline" size={18} color={colors.mutedForeground} />
</Pressable>
))}
</View>
<Button
title="Open Shortcuts app"
variant="secondary"
onPress={() => void Linking.openURL("shortcuts://")}
/>
</View>
);
}
const styles = StyleSheet.create({
stack: {
gap: spacing.md,
},
lead: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
steps: {
borderWidth: 1,
borderRadius: 12,
gap: spacing.sm,
padding: spacing.md,
},
step: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
emphasis: {
fontFamily: fonts.bodyMedium,
},
meta: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
actions: {
gap: spacing.sm,
},
actionRow: {
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
flexDirection: "row",
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
actionCopy: {
flex: 1,
gap: 2,
},
actionTitle: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
actionSubtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
});
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import { GlassSurface } from "@/components/GlassSurface";
import { spacing } from "@/constants/theme";
const AUTH_CARD_RADIUS = 24;
type AuthCardProps = {
children: ReactNode;
style?: StyleProp<ViewStyle>;
};
export function AuthCard({ children, style }: AuthCardProps) {
return (
<GlassSurface radius={AUTH_CARD_RADIUS} style={style}>
<View style={styles.inner}>{children}</View>
</GlassSurface>
);
}
const styles = StyleSheet.create({
inner: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
gap: spacing.lg,
},
});
+45
View File
@@ -0,0 +1,45 @@
import { StyleSheet, Text, View } from "react-native";
import { Logo } from "@/components/Logo";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type AuthCardHeaderProps = {
title: string;
description: string;
};
export function AuthCardHeader({ title, description }: AuthCardHeaderProps) {
const { colors } = useAppTheme();
return (
<View style={styles.wrapper}>
<Logo size="md" />
<View style={styles.copy}>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.description, { color: colors.mutedForeground }]}>
{description}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.md,
},
copy: {
gap: spacing.xs,
},
title: {
fontSize: 24,
fontFamily: fonts.headingSemi,
letterSpacing: -0.3,
},
description: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+34
View File
@@ -0,0 +1,34 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export function AuthDivider() {
const { colors } = useAppTheme();
return (
<View style={styles.row}>
<View style={[styles.line, { backgroundColor: colors.border }]} />
<Text style={[styles.label, { color: colors.mutedForeground }]}>or</Text>
<View style={[styles.line, { backgroundColor: colors.border }]} />
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
line: {
flex: 1,
height: StyleSheet.hairlineWidth,
},
label: {
fontSize: 12,
fontFamily: fonts.bodyMedium,
textTransform: "uppercase",
letterSpacing: 0.6,
},
});
+39
View File
@@ -0,0 +1,39 @@
import { StyleSheet, Text } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type AuthNoticeProps = {
children: string;
};
export function AuthNotice({ children }: AuthNoticeProps) {
const { colors } = useAppTheme();
return (
<Text
style={[
styles.notice,
{
color: colors.mutedForeground,
backgroundColor: colors.muted,
borderColor: colors.border,
},
]}
>
{children}
</Text>
);
}
const styles = StyleSheet.create({
notice: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radii.lg,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
});
+47
View File
@@ -0,0 +1,47 @@
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View, type ViewProps } from "react-native";
import { AuthBackground } from "@/components/AppBackground";
import { FullScreen } from "@/components/Screen";
import { spacing } from "@/constants/theme";
export function AuthScreenLayout({ children, style, ...props }: ViewProps) {
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={[styles.content, style]} {...props}>
{children}
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
},
flex: {
flex: 1,
},
container: {
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
alignSelf: "center",
},
});
@@ -0,0 +1,155 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { InvoiceStatus } from "@/lib/invoice-status";
type ActionItem = {
key: string;
title: string;
subtitle?: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
loading?: boolean;
};
type InvoiceDetailActionsProps = {
status: InvoiceStatus;
clientEmail: string;
onPaymentReminder?: () => void;
paymentReminderLoading?: boolean;
onUpdateStatus: () => void;
updateStatusLoading?: boolean;
onTrackTime: () => void;
};
export function InvoiceDetailActions({
status,
clientEmail,
onPaymentReminder,
paymentReminderLoading,
onUpdateStatus,
updateStatusLoading,
onTrackTime,
}: InvoiceDetailActionsProps) {
const { colors } = useAppTheme();
const rows: ActionItem[] = [];
if ((status === "sent" || status === "overdue") && onPaymentReminder) {
rows.push({
key: "reminder",
title: "Send payment reminder",
subtitle: clientEmail ? `Nudge ${clientEmail}` : "Add a client email first",
icon: "notifications-outline",
onPress: onPaymentReminder,
loading: paymentReminderLoading,
});
}
rows.push(
{
key: "status",
title: "Update status",
subtitle: "Draft, sent, or paid",
icon: "swap-horizontal-outline",
onPress: onUpdateStatus,
loading: updateStatusLoading,
},
{
key: "timer",
title: "Track time",
subtitle: "Clock hours to this invoice",
icon: "timer-outline",
onPress: onTrackTime,
},
);
return (
<View
style={[
styles.card,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<View style={styles.list}>
{rows.map((row, index) => (
<View key={row.key}>
{index > 0 ? (
<View style={[styles.divider, { backgroundColor: colors.border }]} />
) : null}
<Pressable
accessibilityRole="button"
disabled={row.loading}
onPress={row.onPress}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
<Ionicons name={row.icon} size={20} color={colors.foreground} />
</View>
<View style={styles.copy}>
<Text style={[styles.title, { color: colors.foreground }]}>{row.title}</Text>
{row.subtitle ? (
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
{row.subtitle}
</Text>
) : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: radii.lg,
padding: spacing.md,
gap: spacing.sm,
},
list: {
gap: 0,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: spacing.xs,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingVertical: spacing.sm,
},
rowPressed: {
opacity: 0.75,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
copy: {
flex: 1,
gap: 2,
minWidth: 0,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
subtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
});
+129
View File
@@ -0,0 +1,129 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Button } from "@/components/ui/Button";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type SecondaryAction = {
title: string;
subtitle?: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
loading?: boolean;
disabled?: boolean;
};
type InvoiceEditorFooterProps = {
primaryTitle: string;
onPrimary: () => void;
primaryLoading?: boolean;
primaryDisabled?: boolean;
secondary?: SecondaryAction;
};
export function InvoiceEditorFooter({
primaryTitle,
onPrimary,
primaryLoading,
primaryDisabled,
secondary,
}: InvoiceEditorFooterProps) {
const { colors } = useAppTheme();
return (
<View
style={[
styles.card,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<Button
title={primaryTitle}
loading={primaryLoading}
disabled={primaryDisabled}
onPress={onPrimary}
/>
{secondary ? (
<>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<Pressable
accessibilityRole="button"
disabled={secondary.disabled || secondary.loading}
onPress={secondary.onPress}
style={({ pressed }) => [
styles.secondaryRow,
(pressed || secondary.loading) && styles.secondaryPressed,
(secondary.disabled || secondary.loading) && styles.secondaryDisabled,
]}
>
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
<Ionicons name={secondary.icon} size={20} color={colors.foreground} />
</View>
<View style={styles.secondaryCopy}>
<Text style={[styles.secondaryTitle, { color: colors.foreground }]}>
{secondary.title}
</Text>
{secondary.subtitle ? (
<Text style={[styles.secondarySubtitle, { color: colors.mutedForeground }]}>
{secondary.subtitle}
</Text>
) : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: radii.lg,
padding: spacing.md,
gap: spacing.sm,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: spacing.xs,
},
secondaryRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingVertical: spacing.xs,
},
secondaryPressed: {
opacity: 0.75,
},
secondaryDisabled: {
opacity: 0.45,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
secondaryCopy: {
flex: 1,
gap: 2,
minWidth: 0,
},
secondaryTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
secondarySubtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
});
@@ -3,21 +3,34 @@ import { ScrollView, StyleSheet, View } from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { spacing } from "@/constants/theme";
export type InvoiceEditorSection = "edit" | "preview";
export type InvoiceEditorSection = "setup" | "lines" | "preview";
export type InvoiceViewSection = "details" | "preview";
type InvoiceEditorSectionTabsProps = {
value: InvoiceEditorSection;
onChange: (value: InvoiceEditorSection) => void;
editLabel?: string;
previewLabel?: string;
};
type InvoiceEditorSectionTabsProps =
| {
mode?: "edit";
value: InvoiceEditorSection;
onChange: (value: InvoiceEditorSection) => void;
}
| {
mode: "view";
value: InvoiceViewSection;
onChange: (value: InvoiceViewSection) => void;
};
export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
const tabs =
props.mode === "view"
? [
{ id: "details" as const, label: "Details" },
{ id: "preview" as const, label: "PDF" },
]
: [
{ id: "setup" as const, label: "Setup" },
{ id: "lines" as const, label: "Line items" },
{ id: "preview" as const, label: "PDF preview" },
];
export function InvoiceEditorSectionTabs({
value,
onChange,
editLabel = "Edit",
previewLabel = "PDF preview",
}: InvoiceEditorSectionTabsProps) {
return (
<View>
<ScrollView
@@ -25,16 +38,14 @@ export function InvoiceEditorSectionTabs({
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
<FilterChip
label={editLabel}
active={value === "edit"}
onPress={() => onChange("edit")}
/>
<FilterChip
label={previewLabel}
active={value === "preview"}
onPress={() => onChange("preview")}
/>
{tabs.map((tab) => (
<FilterChip
key={tab.id}
label={tab.label}
active={props.value === tab.id}
onPress={() => props.onChange(tab.id as never)}
/>
))}
</ScrollView>
</View>
);
+229
View File
@@ -0,0 +1,229 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
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 { useAppTheme } from "@/contexts/ThemeContext";
import { defaultDueDate } from "@/lib/invoice-number";
type SelectOption = { label: string; value: string };
type InvoiceSetupFormProps = {
businessId: string;
onBusinessIdChange: (value: string) => void;
businessOptions: SelectOption[];
businessError?: string;
businessReadOnly?: boolean;
clientId: string;
onClientIdChange: (value: string) => void;
clientOptions: SelectOption[];
clientError?: string;
clientReadOnly?: boolean;
invoiceNumber: string;
onInvoiceNumberChange?: (value: string) => void;
invoiceNumberReadOnly?: boolean;
issueDate: Date;
onIssueDateChange?: (date: Date) => void;
issueDateReadOnly?: boolean;
dueDate: Date;
onDueDateChange: (date: Date) => void;
taxRate: string;
onTaxRateChange?: (value: string) => void;
taxRateReadOnly?: boolean;
notes: string;
onNotesChange: (value: string) => void;
sendReminderAt?: Date | null;
onSendReminderAtChange?: (date: Date | null) => void;
showSendReminder?: boolean;
};
export function InvoiceSetupForm({
businessId,
onBusinessIdChange,
businessOptions,
businessError,
businessReadOnly = false,
clientId,
onClientIdChange,
clientOptions,
clientError,
clientReadOnly = false,
invoiceNumber,
onInvoiceNumberChange,
invoiceNumberReadOnly = false,
issueDate,
onIssueDateChange,
issueDateReadOnly = false,
dueDate,
onDueDateChange,
taxRate,
onTaxRateChange,
taxRateReadOnly = false,
notes,
onNotesChange,
sendReminderAt,
onSendReminderAtChange,
showSendReminder = false,
}: InvoiceSetupFormProps) {
const { colors } = useAppTheme();
return (
<View style={styles.form}>
{businessOptions.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add a business in Entities before invoicing.
</Text>
) : (
<SelectField
label="Business"
placeholder="Select business…"
value={businessId}
options={businessOptions}
required
error={businessError}
disabled={businessReadOnly}
onValueChange={onBusinessIdChange}
/>
)}
{clientOptions.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add a client in Entities before invoicing.
</Text>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
disabled={clientReadOnly}
onValueChange={onClientIdChange}
/>
)}
{invoiceNumberReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Invoice number
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{invoiceNumber}
</Text>
</View>
) : (
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={onInvoiceNumberChange}
autoCapitalize="characters"
required
/>
)}
{issueDateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Issue date
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{issueDate.toLocaleDateString()}
</Text>
</View>
) : (
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
onIssueDateChange?.(date);
if (dueDate < date) onDueDateChange(defaultDueDate(date));
}}
/>
)}
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
{taxRateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Tax rate
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{taxRate}%
</Text>
</View>
) : (
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={onTaxRateChange}
keyboardType="decimal-pad"
/>
)}
{showSendReminder && onSendReminderAtChange ? (
<>
<DateTimeField
label="Remind me to send"
mode="date"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
onChange={onSendReminderAtChange}
/>
{sendReminderAt ? (
<Pressable onPress={() => onSendReminderAtChange(null)}>
<Text style={[styles.clearReminder, { color: colors.primary }]}>
Clear send reminder
</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={onNotesChange}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</View>
);
}
const styles = StyleSheet.create({
form: {
gap: spacing.sm,
},
hint: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
readOnlyField: {
gap: 4,
paddingVertical: 4,
},
readOnlyLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
readOnlyValue: {
fontFamily: fonts.body,
fontSize: 15,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
clearReminder: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
marginBottom: spacing.xs,
},
});
+4 -4
View File
@@ -65,10 +65,10 @@ function TotalRow({
const styles = StyleSheet.create({
totals: {
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
gap: 6,
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
gap: spacing.xs,
},
row: {
flexDirection: "row",
+58
View File
@@ -0,0 +1,58 @@
import { ScrollView, StyleSheet, View } from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { spacing } from "@/constants/theme";
import type { InvoiceStatus } from "@/lib/invoice-status";
export type InvoiceViewSection = "details" | "preview";
type InvoiceViewChipsProps = {
section: InvoiceViewSection;
onSectionChange: (section: InvoiceViewSection) => void;
status: InvoiceStatus;
onEdit: () => void;
onSend: () => void;
};
export function InvoiceViewChips({
section,
onSectionChange,
status,
onEdit,
onSend,
}: InvoiceViewChipsProps) {
const sendLabel = status === "draft" ? "Send" : "Resend";
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
<FilterChip
label="Details"
active={section === "details"}
onPress={() => onSectionChange("details")}
/>
<FilterChip label="Edit" onPress={onEdit} />
{status !== "paid" ? (
<FilterChip label={sendLabel} onPress={onSend} />
) : null}
<FilterChip
label="View PDF"
active={section === "preview"}
onPress={() => onSectionChange("preview")}
/>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.sm,
paddingVertical: spacing.xs,
},
});
+147 -143
View File
@@ -25,28 +25,10 @@ type LineItemEditorProps = {
isLast?: boolean;
};
export function LineItemsTableHeader() {
function FieldLabel({ children }: { children: string }) {
const { colors } = useAppTheme();
return (
<View style={[headerStyles.row, { borderBottomColor: colors.border }]}>
<Text style={[headerStyles.cell, headerStyles.desc, { color: colors.mutedForeground }]}>
Description
</Text>
<Text style={[headerStyles.cell, headerStyles.date, { color: colors.mutedForeground }]}>
Date
</Text>
<Text style={[headerStyles.cell, headerStyles.hours, { color: colors.mutedForeground }]}>
Hrs
</Text>
<Text style={[headerStyles.cell, headerStyles.rate, { color: colors.mutedForeground }]}>
Rate
</Text>
<Text style={[headerStyles.cell, headerStyles.amt, { color: colors.mutedForeground }]}>
Amt
</Text>
<View style={headerStyles.spacer} />
</View>
<Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
);
}
@@ -68,20 +50,20 @@ export function LineItemEditor({
return (
<View
style={[
styles.row,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
styles.readBlock,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
]}
>
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
<View style={styles.descCol}>
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={2}>
{item.description.trim() || "Untitled line"}
</Text>
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
</Text>
</View>
<Text style={[styles.amount, { color: colors.foreground }]}>
<Text style={[styles.readIndex, { color: colors.mutedForeground }]}>
Line {index + 1}
</Text>
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
{item.description.trim() || "Untitled line"}
</Text>
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
</Text>
<Text style={[styles.readAmount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
</View>
@@ -92,154 +74,159 @@ export function LineItemEditor({
<View
style={[
styles.editBlock,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
]}
>
<View style={styles.editTop}>
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
<TextInput
value={item.description}
onChangeText={(description) => onChange({ description })}
placeholder="What was done?"
placeholderTextColor={colors.mutedForeground}
style={[
styles.descriptionInput,
{
color: colors.foreground,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
/>
</View>
<Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
<View style={styles.metricsRow}>
<CompactDateField
value={item.date}
onChange={(date) => onChange({ date })}
style={styles.dateField}
/>
<CompactStepperInput
value={item.hours}
onChangeText={(hours) => onChange({ hours })}
step={0.25}
style={styles.hoursField}
/>
<View style={[styles.rateField, { borderColor: colors.border, backgroundColor: colors.cardGlass }]}>
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
<TextInput
value={item.rate}
onChangeText={(rate) => onChange({ rate })}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.mutedForeground}
style={[styles.rateInput, { color: colors.foreground }]}
<TextInput
value={item.description}
onChangeText={(description) => onChange({ description })}
placeholder="What was done?"
placeholderTextColor={colors.mutedForeground}
style={[
styles.descriptionInput,
{
color: colors.foreground,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
/>
<View style={styles.fieldsRow}>
<View style={styles.fieldCol}>
<FieldLabel>Date</FieldLabel>
<CompactDateField
value={item.date}
onChange={(date) => onChange({ date })}
style={styles.fieldControl}
/>
</View>
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
<View style={styles.fieldCol}>
<FieldLabel>Hours</FieldLabel>
<CompactStepperInput
value={item.hours}
onChangeText={(hours) => onChange({ hours })}
step={0.25}
style={styles.fieldControl}
/>
</View>
<View style={styles.fieldCol}>
<FieldLabel>Rate</FieldLabel>
<View
style={[
styles.rateField,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
<TextInput
value={item.rate}
onChangeText={(rate) => onChange({ rate })}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.mutedForeground}
style={[styles.rateInput, { color: colors.foreground }]}
/>
</View>
</View>
</View>
<View style={styles.footerRow}>
<View style={styles.amountGroup}>
<Text style={[styles.amountLabel, { color: colors.mutedForeground }]}>Amount</Text>
<Text style={[styles.amountValue, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Remove line item"
onPress={onRemove}
hitSlop={8}
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}
style={({ pressed }) => [
styles.remove,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
pressed && styles.removePressed,
]}
>
<Ionicons name="trash-outline" size={17} color={colors.destructive} />
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
</View>
);
}
const headerStyles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
const styles = StyleSheet.create({
readBlock: {
paddingVertical: spacing.md,
gap: spacing.xs,
paddingBottom: spacing.xs,
marginBottom: spacing.xs,
borderBottomWidth: 1,
},
cell: {
readIndex: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.4,
},
desc: { flex: 1, paddingLeft: 22 },
date: { width: 72 },
hours: { width: 88, textAlign: "center" },
rate: { width: 72, textAlign: "center" },
amt: { width: 64, textAlign: "right" },
spacer: { width: 32 },
});
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
},
editBlock: {
paddingVertical: spacing.sm,
gap: spacing.xs,
},
editTop: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
index: {
width: 18,
fontFamily: fonts.bodySemiBold,
fontSize: 12,
textAlign: "center",
},
descCol: {
flex: 1,
gap: 2,
},
readTitle: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
lineHeight: 18,
fontSize: 15,
lineHeight: 20,
},
readSub: {
fontFamily: fonts.body,
fontSize: 13,
},
readAmount: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
marginTop: 2,
},
editBlock: {
paddingVertical: spacing.md,
gap: spacing.sm,
},
lineLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.4,
},
descriptionInput: {
flex: 1,
minHeight: 36,
width: "100%",
minHeight: 40,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.sm,
fontFamily: fonts.body,
fontSize: 14,
paddingVertical: 6,
fontSize: 15,
paddingVertical: 8,
},
metricsRow: {
fieldsRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingLeft: 22,
gap: spacing.sm,
},
dateField: {
width: 72,
fieldCol: {
flex: 1,
gap: 4,
minWidth: 0,
},
hoursField: {
width: 88,
fieldLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.3,
},
fieldControl: {
width: "100%",
},
rateField: {
width: 72,
minHeight: 36,
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: radii.md,
minHeight: 36,
paddingHorizontal: spacing.xs,
},
ratePrefix: {
@@ -248,23 +235,40 @@ const styles = StyleSheet.create({
},
rateInput: {
flex: 1,
fontFamily: fonts.body,
fontFamily: fonts.bodyMedium,
fontSize: 13,
paddingVertical: 4,
textAlign: "right",
minWidth: 0,
},
amount: {
width: 64,
fontFamily: fonts.bodySemiBold,
fontSize: 13,
textAlign: "right",
footerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
marginTop: 2,
},
amountEdit: {
amountGroup: {
flex: 1,
flexDirection: "row",
alignItems: "baseline",
gap: spacing.sm,
},
amountLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0.3,
},
amountValue: {
fontFamily: fonts.bodySemiBold,
fontSize: 17,
},
remove: {
width: 32,
height: 36,
width: 40,
height: 40,
borderRadius: radii.md,
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
+53
View File
@@ -0,0 +1,53 @@
import * as WebBrowser from "expo-web-browser";
import { StyleSheet, Text } from "react-native";
import { fonts } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
type LegalAgreementNoticeProps = {
action: string;
};
function openLegalPage(baseUrl: string, path: "/terms" | "/privacy") {
const origin = baseUrl.replace(/\/$/, "");
void WebBrowser.openBrowserAsync(`${origin}${path}`);
}
export function LegalAgreementNotice({ action }: LegalAgreementNoticeProps) {
const { colors } = useAppTheme();
const { apiUrl } = useAccounts();
return (
<Text style={[styles.text, { color: colors.mutedForeground }]}>
By {action}, you agree to our{" "}
<Text
style={[styles.link, { color: colors.foreground }]}
onPress={() => openLegalPage(apiUrl, "/terms")}
>
Terms of Service
</Text>{" "}
and{" "}
<Text
style={[styles.link, { color: colors.foreground }]}
onPress={() => openLegalPage(apiUrl, "/privacy")}
>
Privacy Policy
</Text>
.
</Text>
);
}
const styles = StyleSheet.create({
text: {
textAlign: "center",
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 18,
},
link: {
fontFamily: fonts.bodyMedium,
textDecorationLine: "underline",
},
});
@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { AppState } from "react-native";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { api } from "@/lib/trpc";
/** Keeps the iOS Live Activity in sync while a timer runs — app-wide, not just on the Timer tab. */
export function TimeClockLiveActivitySync() {
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 60_000,
});
const running = runningQuery.data;
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity(running, seconds);
};
sync();
const interval = setInterval(sync, 60_000);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") sync();
});
return () => {
clearInterval(interval);
subscription.remove();
};
}, [running]);
return null;
}
+97 -131
View File
@@ -31,12 +31,12 @@ import {
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
@@ -90,6 +90,7 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [stopNote, setStopNote] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now");
@@ -139,18 +140,6 @@ export function TimeClockPanel({
},
});
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();
@@ -175,6 +164,7 @@ export function TimeClockPanel({
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
setStopNote("");
},
});
@@ -194,7 +184,7 @@ export function TimeClockPanel({
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description?.trim() ?? "");
setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
@@ -247,24 +237,6 @@ export function TimeClockPanel({
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(
@@ -404,43 +376,19 @@ export function TimeClockPanel({
async function handleClockOut() {
try {
await clockOut.mutateAsync({
description: description.trim() ? description.trim() : undefined,
description: stopNote.trim() ? stopNote.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 runningTitle = formatRunningTimerLabel(running?.description);
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
@@ -451,7 +399,6 @@ export function TimeClockPanel({
.filter(Boolean)
.join(" · ");
const controlsDisabled = Boolean(running && updateRunning.isPending);
function renderClientChip(client: (typeof clients)[number]) {
return (
@@ -459,11 +406,7 @@ export function TimeClockPanel({
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningClientChange(client.id);
else selectClient(client.id);
}}
onPress={() => selectClient(client.id)}
/>
);
}
@@ -487,18 +430,18 @@ export function TimeClockPanel({
>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}>
<View style={[styles.hero, running && styles.heroRunning]}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabel}>Running</Text>
<Text style={styles.heroLabelRunning}>Timer running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)}
{runningMeta ? ` · ${runningMeta}` : ""}
</Text>
<Text style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? (
<Text style={styles.runningMeta}>{runningMeta}</Text>
) : null}
</>
) : (
<Text style={styles.idleHint}>
@@ -510,13 +453,37 @@ export function TimeClockPanel({
) : null}
<GlassSurface style={styles.setupCard}>
<Input
label="Title"
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
{running ? (
<View style={styles.formSection}>
<Input
label="Note on stop (optional)"
value={stopNote}
onChangeText={setStopNote}
placeholder={
running.description?.trim()
? running.description
: "Update description when you stop"
}
returnKeyType="done"
/>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
</View>
) : (
<>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholderTextColor={colors.mutedForeground}
returnKeyType="done"
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
style={[styles.titleField, { color: colors.foreground }]}
/>
<View style={styles.setupSection}>
@@ -533,10 +500,7 @@ export function TimeClockPanel({
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => {
if (controlsDisabled) return;
setClientsExpanded((open) => !open);
}}
onPress={() => setClientsExpanded((open) => !open)}
/>
) : null}
</View>
@@ -547,7 +511,7 @@ export function TimeClockPanel({
) : null}
</>
)}
{clockInErrors.clientId && !running ? (
{clockInErrors.clientId ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null}
</View>
@@ -559,11 +523,7 @@ export function TimeClockPanel({
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange("");
else setInvoiceId("");
}}
onPress={() => setInvoiceId("")}
/>
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
@@ -572,11 +532,7 @@ export function TimeClockPanel({
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange(invoice.id);
else setInvoiceId(invoice.id);
}}
onPress={() => setInvoiceId(invoice.id)}
/>
);
})}
@@ -584,7 +540,7 @@ export function TimeClockPanel({
</View>
) : null}
{!running && clientId ? (
{clientId ? (
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
@@ -690,28 +646,20 @@ export function TimeClockPanel({
) : null}
</View>
) : null}
</View>
) : null}
<Button
title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
showArrow={!clockIn.isPending}
onPress={handleClockIn}
/>
</>
)}
</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">
<Card title="Today's entries">
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
@@ -720,7 +668,7 @@ export function TimeClockPanel({
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
@@ -762,41 +710,52 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
},
hero: {
padding: spacing.md,
padding: spacing.lg,
gap: spacing.sm,
},
heroRunning: {
alignItems: "center",
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
heroLabel: {
fontSize: 13,
heroLabelRunning: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.4,
color: colors.primary,
},
timerValue: {
fontSize: 52,
lineHeight: 56,
fontSize: 56,
lineHeight: 60,
fontFamily: fonts.mono,
color: colors.foreground,
color: colors.primary,
fontVariant: ["tabular-nums"],
textAlign: "center",
},
runningTitle: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
color: colors.foreground,
textAlign: "center",
},
runningMeta: {
fontSize: 13,
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
idleHint: {
fontSize: 14,
@@ -805,20 +764,27 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
lineHeight: 20,
},
setupCard: {
padding: spacing.md,
padding: spacing.lg,
gap: spacing.lg,
},
cardTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
formSection: {
gap: spacing.md,
},
titleField: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
minHeight: 48,
paddingVertical: spacing.xs,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
titleInput: {
minHeight: 44,
textAlignVertical: "center",
},
titleInputPlaceholder: {
textAlign: "center",
},
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
+24 -2
View File
@@ -3,9 +3,11 @@ import {
Pressable,
StyleSheet,
Text,
View,
type PressableProps,
type ViewStyle,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
@@ -15,6 +17,7 @@ type ButtonProps = PressableProps & {
loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle;
showArrow?: boolean;
};
export function Button({
@@ -23,6 +26,7 @@ export function Button({
variant = "primary",
disabled,
style,
showArrow = false,
...props
}: ButtonProps) {
const { colors } = useAppTheme();
@@ -68,7 +72,17 @@ export function Button({
color={variant === "primary" ? colors.primaryForeground : colors.primary}
/>
) : (
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
<View style={styles.content}>
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
{showArrow ? (
<Ionicons
name="arrow-forward"
size={16}
color={labelStyles[variant].color}
style={styles.arrow}
/>
) : null}
</View>
)}
</Pressable>
);
@@ -76,12 +90,20 @@ export function Button({
const styles = StyleSheet.create({
base: {
minHeight: 40,
minHeight: 44,
borderRadius: radii.lg,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.md,
},
content: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
arrow: {
marginTop: 1,
},
pressed: {
opacity: 0.92,
},
+72 -20
View File
@@ -5,6 +5,7 @@ import {
View,
type TextInputProps,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
@@ -13,31 +14,60 @@ type InputProps = TextInputProps & {
label: string;
error?: string;
required?: boolean;
leftIcon?: keyof typeof Ionicons.glyphMap;
labelAccessory?: React.ReactNode;
hint?: string;
};
export function Input({ label, error, required, style, ...props }: InputProps) {
export function Input({
label,
error,
required,
leftIcon,
labelAccessory,
hint,
style,
...props
}: InputProps) {
const { colors } = useAppTheme();
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<TextInput
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
{
borderColor: colors.border,
color: colors.foreground,
backgroundColor: colors.cardGlass,
},
error && { borderColor: colors.destructive },
style,
]}
{...props}
/>
<View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
{labelAccessory}
</View>
<View style={styles.field}>
{leftIcon ? (
<Ionicons
name={leftIcon}
size={16}
color={colors.mutedForeground}
style={styles.leftIcon}
/>
) : null}
<TextInput
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
leftIcon && styles.inputWithIcon,
{
borderColor: colors.border,
color: colors.foreground,
backgroundColor: colors.cardGlass,
},
error && { borderColor: colors.destructive },
style,
]}
{...props}
/>
</View>
{hint && !error ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
) : null}
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
</View>
);
@@ -47,18 +77,40 @@ const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
labelRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
field: {
position: "relative",
justifyContent: "center",
},
leftIcon: {
position: "absolute",
left: spacing.md,
zIndex: 1,
},
input: {
minHeight: 40,
minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: fonts.body,
},
inputWithIcon: {
paddingLeft: spacing.md + 24,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
},
error: {
fontSize: 13,
fontFamily: fonts.body,