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:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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%",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -145,7 +145,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 20,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
textAlign: "center",
|
||||
letterSpacing: 6,
|
||||
},
|
||||
error: {
|
||||
fontSize: 13,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user