Fix Live Activity lock screen rendering and polish multi-account auth.

Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 01:23:36 -04:00
co-authored by Cursor
parent e6ea3d7c5d
commit 32ffe782ea
35 changed files with 1659 additions and 442 deletions
+264
View File
@@ -0,0 +1,264 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react";
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatServerHost } from "@/lib/server-mode";
function initials(name: string, email: string) {
const source = name.trim() || email.trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
}
return (source[0] ?? "?").toUpperCase();
}
function displayName(name: string, email: string) {
const trimmed = name.trim();
if (trimmed) return trimmed.split(/\s+/)[0] ?? trimmed;
return email.split("@")[0] ?? email;
}
/** Header control to switch signed-in accounts or add another. */
export function AccountSwitcher() {
const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
switchAccount,
clearActiveAccount,
} = useAccounts();
const [open, setOpen] = useState(false);
const label = displayName(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
const avatar = initials(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
async function handleAddAccount() {
setOpen(false);
await authClient.signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}
async function handleSwitch(accountId: string) {
if (accountId === activeAccountId) {
setOpen(false);
return;
}
setOpen(false);
await switchAccount(accountId);
}
return (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Switch account"
hitSlop={8}
onPress={() => setOpen(true)}
style={styles.hit}
>
<View style={[styles.row, { backgroundColor: colors.muted }]}>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{avatar}
</Text>
</View>
<Text
style={[styles.name, { color: colors.foreground }]}
numberOfLines={1}
>
{label}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.mutedForeground} />
</View>
</Pressable>
<Modal animationType="fade" onRequestClose={() => setOpen(false)} transparent visible={open}>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<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 }]}>Accounts</Text>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<Pressable
key={account.id}
accessibilityRole="button"
onPress={() => void handleSwitch(account.id)}
style={({ pressed }) => [
styles.accountRow,
{
borderBottomColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{initials(account.name, account.email)}
</Text>
</View>
<View style={styles.accountMeta}>
<Text style={[styles.accountName, { color: colors.foreground }]}>
{account.name || account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
{account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
{formatServerHost(account.instanceUrl)}
</Text>
</View>
{isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
<Pressable
accessibilityRole="button"
onPress={() => void handleAddAccount()}
style={({ pressed }) => [
styles.addRow,
{ borderTopColor: colors.border },
pressed && styles.pressed,
]}
>
<Ionicons name="add-circle-outline" size={22} color={colors.primary} />
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
hit: {
flexShrink: 1,
maxWidth: "58%",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingLeft: 4,
paddingRight: 8,
minHeight: 32,
borderRadius: radii.pill,
},
avatar: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
avatarText: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
},
name: {
flexShrink: 1,
fontFamily: fonts.bodyMedium,
fontSize: 14,
lineHeight: 18,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
maxHeight: "70%",
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
},
sheetTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
done: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
accountRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
},
accountMeta: {
flex: 1,
gap: 2,
},
accountName: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
accountSub: {
fontFamily: fonts.body,
fontSize: 12,
lineHeight: 16,
},
addRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
paddingVertical: spacing.lg,
borderTopWidth: StyleSheet.hairlineWidth,
},
addLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
pressed: {
opacity: 0.75,
},
});
+39 -35
View File
@@ -1,15 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { LogoMark } from "@/components/Logo";
import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppLock } from "@/contexts/AppLockContext";
@@ -28,11 +26,13 @@ export function AppLockOverlay() {
} = useAppLock();
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const promptedRef = useRef(false);
useEffect(() => {
if (!isLocked) {
setPin("");
setError("");
promptedRef.current = false;
}
}, [isLocked]);
@@ -40,12 +40,18 @@ export function AppLockOverlay() {
if (!enabled || !isLocked || !biometricEnabled || !biometricAvailable) {
return;
}
if (promptedRef.current) return;
void unlockWithBiometric().then((success) => {
if (!success) return;
setPin("");
setError("");
});
const timer = setTimeout(() => {
promptedRef.current = true;
void unlockWithBiometric().then((success) => {
if (!success) return;
setPin("");
setError("");
});
}, 400);
return () => clearTimeout(timer);
}, [enabled, isLocked, biometricEnabled, biometricAvailable, unlockWithBiometric]);
if (!enabled || !isLocked) {
@@ -64,6 +70,7 @@ export function AppLockOverlay() {
}
async function tryBiometric() {
promptedRef.current = true;
const success = await unlockWithBiometric();
if (!success) {
setError(`Could not unlock with ${biometricLabel}`);
@@ -74,8 +81,8 @@ export function AppLockOverlay() {
<Modal visible animationType="fade" transparent={false}>
<View style={[styles.screen, { backgroundColor: colors.background }]}>
<View style={styles.content}>
<LogoMark size={56} />
<Text style={[styles.title, { color: colors.foreground }]}>beenvoice is locked</Text>
<Logo size="md" />
<Text style={[styles.title, { color: colors.foreground }]}>Locked</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Enter your PIN to continue
</Text>
@@ -104,20 +111,18 @@ export function AppLockOverlay() {
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
<View style={styles.actions}>
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
{biometricEnabled && biometricAvailable ? (
<Pressable
accessibilityRole="button"
onPress={() => void tryBiometric()}
style={styles.biometricButton}
>
<Ionicons name="finger-print-outline" size={20} color={colors.primary} />
<Text style={[styles.biometricLabel, { color: colors.primary }]}>
Unlock with {biometricLabel}
</Text>
</Pressable>
) : null}
{biometricAvailable ? (
<Button
title={`Unlock with ${biometricLabel}`}
variant="secondary"
onPress={() => void tryBiometric()}
style={styles.biometricButton}
/>
) : null}
</View>
</View>
</View>
</Modal>
@@ -133,6 +138,9 @@ const styles = StyleSheet.create({
content: {
alignItems: "center",
gap: spacing.md,
width: "100%",
maxWidth: 320,
alignSelf: "center",
},
title: {
fontSize: 22,
@@ -147,28 +155,24 @@ const styles = StyleSheet.create({
},
pinInput: {
width: "100%",
maxWidth: 280,
borderWidth: 1,
borderRadius: 12,
minHeight: 52,
paddingHorizontal: spacing.md,
fontSize: 24,
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
letterSpacing: 8,
},
error: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
textAlign: "center",
},
actions: {
width: "100%",
gap: spacing.sm,
},
biometricButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
},
biometricLabel: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
width: "100%",
},
});
+227
View File
@@ -0,0 +1,227 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
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 {
formatServerHost,
isServerConfigValid,
resolveServerMode,
resolveServerUrl,
SERVER_MODE_OPTIONS,
type ServerMode,
} from "@/lib/server-mode";
type AuthServerPickerProps = {
onReadyChange?: (ready: boolean) => void;
};
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
if (mode === "official") return "Official";
const host = formatServerHost(selfHostedUrl);
return host || "Self-hosted";
}
export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
const { colors } = useAppTheme();
const { apiUrl, setInstanceUrl } = useAccounts();
const [expanded, setExpanded] = useState(false);
const [mode, setMode] = useState<ServerMode>(() => resolveServerMode(apiUrl));
const [selfHostedUrl, setSelfHostedUrl] = useState(() =>
resolveServerMode(apiUrl) === "self-hosted" ? apiUrl : "",
);
const [urlError, setUrlError] = useState<string | null>(null);
const ready = isServerConfigValid(mode, selfHostedUrl);
useEffect(() => {
onReadyChange?.(ready);
}, [ready, onReadyChange]);
useEffect(() => {
const nextMode = resolveServerMode(apiUrl);
setMode(nextMode);
if (nextMode === "self-hosted") {
setSelfHostedUrl(apiUrl);
}
}, [apiUrl]);
async function applyMode(nextMode: ServerMode) {
setMode(nextMode);
setUrlError(null);
if (nextMode === "official") {
try {
await setInstanceUrl(DEFAULT_API_URL);
setExpanded(false);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not set server");
}
return;
}
setExpanded(true);
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) return;
try {
await setInstanceUrl(resolved);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not set server");
}
}
async function commitSelfHostedUrl() {
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) {
setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
return;
}
try {
const saved = await setInstanceUrl(resolved);
setSelfHostedUrl(saved);
setUrlError(null);
setExpanded(false);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not save server URL");
}
}
return (
<View style={styles.wrapper}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
onPress={() => setExpanded((open) => !open)}
hitSlop={8}
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
>
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
Server ·{" "}
<Text style={[styles.summary, { color: colors.foreground }]}>
{modeSummary(mode, selfHostedUrl)}
</Text>
</Text>
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{expanded ? (
<View
style={[
styles.panel,
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
]}
>
{SERVER_MODE_OPTIONS.map((option) => {
const selected = option.value === mode;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected }}
onPress={() => void applyMode(option.value)}
style={({ pressed }) => [
styles.option,
{
borderColor: colors.border,
backgroundColor: selected ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<Text style={[styles.optionLabel, { color: colors.foreground }]}>
{option.label}
</Text>
{selected ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
{mode === "self-hosted" ? (
<>
<Input
label="Server URL"
value={selfHostedUrl}
onChangeText={(value) => {
setSelfHostedUrl(value);
setUrlError(null);
}}
onBlur={() => void commitSelfHostedUrl()}
onSubmitEditing={() => void commitSelfHostedUrl()}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="beenvoice.app or localhost:3000"
required
error={urlError ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Use your Mac&apos;s LAN IP on a physical device.
</Text>
</>
) : null}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
marginBottom: spacing.md,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
pressed: {
opacity: 0.7,
},
triggerText: {
fontSize: 13,
fontFamily: fonts.body,
},
summary: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
panel: {
borderWidth: 1,
borderRadius: 14,
padding: spacing.md,
gap: spacing.sm,
},
option: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 44,
paddingHorizontal: spacing.md,
borderRadius: 10,
borderWidth: 1,
},
optionLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 16,
},
});
-1
View File
@@ -145,7 +145,6 @@ const styles = StyleSheet.create({
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
letterSpacing: 6,
},
error: {
fontSize: 13,
+3 -3
View File
@@ -1,19 +1,19 @@
import { StyleSheet, View } from "react-native";
import { ClockedInIndicator } from "@/components/ClockedInIndicator";
import { AccountSwitcher } from "@/components/AccountSwitcher";
import { Logo } from "@/components/Logo";
import { spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
/** Wordmark left, clocked-in indicator right — sits on TopChromeBar blur. */
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome() {
const { isDark } = useAppTheme();
return (
<View style={styles.row}>
<Logo size="xs" onDark={isDark} />
<ClockedInIndicator />
<AccountSwitcher />
</View>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
TOP_CHROME_ROW_HEIGHT,
} from "@/lib/top-chrome-insets";
/** Blurred status-bar chrome with logo + clocked-in indicator. */
/** Blurred status-bar chrome with logo + account switcher. */
export function TopChromeBar() {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
+12 -5
View File
@@ -17,6 +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 { api } from "@/lib/trpc";
type BusinessFormValues = {
@@ -153,10 +154,7 @@ export function BusinessForm({
}
function handleSave() {
if (!values.name.trim()) {
setFieldError("Business name is required");
return;
}
if (!canSave) return;
const payload = buildPayload();
@@ -186,6 +184,8 @@ export function BusinessForm({
}
const saving = createBusiness.isPending || updateBusiness.isPending;
const nameError = values.name.trim() ? undefined : "Business name is required";
const canSave = isRequiredString(values.name);
return (
<KeyboardAvoidingView
@@ -199,7 +199,13 @@ export function BusinessForm({
keyboardShouldPersistTaps="handled"
>
<Card title="Profile">
<Input label="Name" value={values.name} onChangeText={(v) => patch("name", v)} />
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Nickname"
value={values.nickname}
@@ -281,6 +287,7 @@ export function BusinessForm({
<Button
title={mode === "create" ? "Create business" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
+18 -5
View File
@@ -15,6 +15,7 @@ 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 { api } from "@/lib/trpc";
export type ClientFormValues = {
@@ -124,10 +125,7 @@ export function ClientForm({
}
function handleSave() {
if (!values.name.trim()) {
setFieldError("Name is required");
return;
}
if (!canSave) return;
const rate = values.defaultHourlyRate.trim()
? Number(values.defaultHourlyRate)
@@ -178,6 +176,13 @@ export function ClientForm({
const saving = createClient.isPending || updateClient.isPending;
const nameError = values.name.trim() ? undefined : "Name is required";
const rateError =
values.defaultHourlyRate.trim() && parseNonNegativeNumber(values.defaultHourlyRate) === null
? "Hourly rate must be a valid number"
: undefined;
const canSave = isRequiredString(values.name) && !rateError;
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
@@ -190,7 +195,13 @@ export function ClientForm({
keyboardShouldPersistTaps="handled"
>
<Card title="Contact">
<Input label="Name" value={values.name} onChangeText={(v) => patch("name", v)} />
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Email"
value={values.email}
@@ -238,6 +249,7 @@ export function ClientForm({
onChangeText={(v) => patch("defaultHourlyRate", v)}
keyboardType="decimal-pad"
placeholder="Optional"
error={rateError}
/>
<Input
label="Currency"
@@ -254,6 +266,7 @@ export function ClientForm({
<Button
title={mode === "create" ? "Create client" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
+36 -13
View File
@@ -14,13 +14,14 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { tabLayout } from "@/lib/tab-layout";
import { formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { describeClockOutOutcome, formatElapsedSeconds } from "@/lib/time-clock";
import { DEFAULT_CLOCK_DESCRIPTION, describeClockOutOutcome, formatElapsedSeconds, resolveClockDescription } from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
@@ -48,7 +49,7 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [description, setDescription] = useState(DEFAULT_CLOCK_DESCRIPTION);
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
@@ -109,7 +110,7 @@ export function TimeClockPanel({
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
setDescription(DEFAULT_CLOCK_DESCRIPTION);
},
});
@@ -117,7 +118,7 @@ export function TimeClockPanel({
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description);
setDescription(running.description?.trim() || DEFAULT_CLOCK_DESCRIPTION);
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
@@ -146,7 +147,7 @@ export function TimeClockPanel({
};
sync();
const interval = setInterval(sync, 30_000);
const interval = setInterval(sync, 15_000);
return () => clearInterval(interval);
}, [running, description]);
@@ -169,12 +170,24 @@ export function TimeClockPanel({
[billableInvoices],
);
const clockInErrors = useMemo(() => {
const next: { clientId?: string; rate?: string } = {};
if (!clientId) next.clientId = "Select a client";
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
return next;
}, [clientId, rateText]);
const canClockIn = Object.keys(clockInErrors).length === 0;
async function handleClockIn() {
if (!canClockIn) return;
try {
const backdated =
Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined;
await clockIn.mutateAsync({
description: description.trim(),
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: rate || undefined,
@@ -188,7 +201,9 @@ export function TimeClockPanel({
async function handleClockOut() {
try {
await clockOut.mutateAsync({ description: description.trim() || undefined });
await clockOut.mutateAsync({
description: description.trim() ? description.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
@@ -268,7 +283,7 @@ export function TimeClockPanel({
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningTitle}>
{description.trim() || "No description"}
{resolveClockDescription(description)}
</Text>
<Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)}
@@ -307,7 +322,7 @@ export function TimeClockPanel({
label="Description"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholder={DEFAULT_CLOCK_DESCRIPTION}
/>
</View>
</Card>
@@ -319,6 +334,8 @@ export function TimeClockPanel({
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clockInErrors.clientId}
onValueChange={(next) => {
setClientId(next);
setInvoiceId("");
@@ -342,7 +359,7 @@ export function TimeClockPanel({
label="Description"
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholder={DEFAULT_CLOCK_DESCRIPTION}
/>
<Input
@@ -350,7 +367,8 @@ export function TimeClockPanel({
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder="0.00"
placeholder="Optional"
error={clockInErrors.rate}
/>
<DateTimeField
@@ -374,7 +392,12 @@ export function TimeClockPanel({
onPress={handleClockOut}
/>
) : (
<Button title="Clock in" loading={clockIn.isPending} onPress={handleClockIn} />
<Button
title="Clock in"
loading={clockIn.isPending}
disabled={!canClockIn}
onPress={handleClockIn}
/>
)}
{todayEntries.length > 0 ? (
@@ -387,7 +410,7 @@ export function TimeClockPanel({
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{entry.description || "No description"}</Text>
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
+6 -2
View File
@@ -12,14 +12,18 @@ import { fonts, radii, spacing } from "@/constants/theme";
type InputProps = TextInputProps & {
label: string;
error?: string;
required?: boolean;
};
export function Input({ label, error, style, ...props }: InputProps) {
export function Input({ label, error, required, style, ...props }: InputProps) {
const { colors } = useAppTheme();
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<TextInput
placeholderTextColor={colors.mutedForeground}
style={[
+16 -2
View File
@@ -23,6 +23,8 @@ type SelectFieldProps = {
value: string;
options: SelectOption[];
disabled?: boolean;
required?: boolean;
error?: string;
onValueChange: (value: string) => void;
};
@@ -32,6 +34,8 @@ export function SelectField({
value,
options,
disabled,
required,
error,
onValueChange,
}: SelectFieldProps) {
const { colors } = useAppTheme();
@@ -40,7 +44,10 @@ export function SelectField({
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<Pressable
accessibilityRole="button"
disabled={disabled}
@@ -48,7 +55,7 @@ export function SelectField({
style={({ pressed }) => [
styles.trigger,
{
borderColor: colors.borderGlass,
borderColor: error ? colors.destructive : colors.borderGlass,
backgroundColor: colors.cardGlass,
},
disabled && styles.triggerDisabled,
@@ -122,6 +129,9 @@ export function SelectField({
</Pressable>
</Pressable>
</Modal>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
</View>
);
}
@@ -134,6 +144,10 @@ const styles = StyleSheet.create({
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
trigger: {
minHeight: 44,
borderWidth: 1,