Refresh home and more navigation

This commit is contained in:
2026-07-01 02:46:39 -04:00
parent 6497ee4dec
commit 530d0dc34d
21 changed files with 1043 additions and 594 deletions
+35
View File
@@ -1,4 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react";
import {
ActivityIndicator,
@@ -82,6 +83,11 @@ export function AccountSwitcher() {
}
}
function handleOpenSettings() {
setOpen(false);
router.push("/(app)/more/settings" as never);
}
function handleRemove(accountId: string, label: string) {
confirmRemoveAccount(
label,
@@ -219,6 +225,22 @@ export function AccountSwitcher() {
<Ionicons name="add-circle-outline" size={22} color={colors.primary} />
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={handleOpenSettings}
style={({ pressed }) => [
styles.settingsRow,
{ borderTopColor: colors.border },
pressed && styles.pressed,
]}
>
<Ionicons name="settings-outline" size={21} color={colors.mutedForeground} />
<Text style={[styles.settingsLabel, { color: colors.foreground }]}>
Settings
</Text>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
@@ -333,6 +355,19 @@ const styles = StyleSheet.create({
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
settingsRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
},
settingsLabel: {
flex: 1,
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
pressed: {
opacity: 0.75,
},
+1 -13
View File
@@ -105,23 +105,11 @@ export function ShortcutHandler() {
const clientId =
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
if (!clientId) {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
Alert.alert(
"Choose a client",
"Open the time clock and pick a client once — shortcuts will use it next time.",
);
return;
}
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
await clockIn.mutateAsync({
clientId,
clientId: clientId || "",
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
rate: rate ?? undefined,
});
+3
View File
@@ -72,6 +72,9 @@ const createSwipeableRowStyles = (colors: ThemeColors) =>
StyleSheet.create({
row: {
backgroundColor: colors.background,
borderRadius: radii.lg,
overflow: "hidden",
marginBottom: spacing.xs,
},
actions: {
flexDirection: "row",
+3 -2
View File
@@ -7,16 +7,17 @@ import { useAppTheme } from "@/contexts/ThemeContext";
type TabPageProps = {
children: ReactNode;
showMoreBack?: boolean;
};
/** Tab root — pinned top chrome, scrollable body below. */
export function TabPage({ children }: TabPageProps) {
export function TabPage({ children, showMoreBack = false }: TabPageProps) {
const { isDark } = useAppTheme();
return (
<View style={styles.root}>
<StatusBar style={isDark ? "light" : "dark"} />
<TopChromeBar />
<TopChromeBar showMoreBack={showMoreBack} />
<View style={styles.content}>{children}</View>
</View>
);
+10
View File
@@ -19,11 +19,15 @@ export function TabScrollView({
header,
children,
contentContainerStyle,
refreshControl,
style,
bounces,
alwaysBounceVertical,
...props
}: TabScrollViewProps) {
const scrollRef = useRef<ScrollView>(null);
const bottomPadding = useTabScreenScrollPadding();
const canRefresh = Boolean(refreshControl);
useScrollToTop(scrollRef);
@@ -37,6 +41,12 @@ export function TabScrollView({
contentContainerStyle,
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
automaticallyAdjustContentInsets={false}
automaticallyAdjustKeyboardInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
bounces={bounces ?? canRefresh}
alwaysBounceVertical={alwaysBounceVertical ?? canRefresh}
refreshControl={refreshControl}
scrollIndicatorInsets={{ bottom: bottomPadding }}
{...props}
>
+51 -4
View File
@@ -1,18 +1,49 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { StyleSheet, View } from "react-native";
import { Pressable, Text } from "react-native";
import { AccountSwitcher } from "@/components/AccountSwitcher";
import { Logo } from "@/components/Logo";
import { spacing } from "@/constants/theme";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
type TopChromeProps = {
showMoreBack?: boolean;
};
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome() {
const { isDark } = useAppTheme();
export function TopChrome({ showMoreBack = false }: TopChromeProps) {
const { colors, isDark } = useAppTheme();
function handleBack() {
if (router.canGoBack()) {
router.back();
return;
}
router.replace("/(app)/more" as never);
}
return (
<View style={styles.row}>
<Logo size="xs" onDark={isDark} />
{showMoreBack ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Back to More"
onPress={handleBack}
style={({ pressed }) => [
styles.backButton,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
pressed && styles.pressed,
]}
>
<Ionicons name="chevron-back" size={18} color={colors.foreground} />
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text>
</Pressable>
) : (
<Logo size="xs" onDark={isDark} />
)}
<AccountSwitcher />
</View>
);
@@ -26,4 +57,20 @@ const styles = StyleSheet.create({
height: TOP_CHROME_ROW_HEIGHT,
paddingHorizontal: spacing.md,
},
backButton: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderWidth: 1,
borderRadius: radii.pill,
},
backLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 13,
},
pressed: {
opacity: 0.82,
},
});
+6 -2
View File
@@ -10,8 +10,12 @@ import {
TOP_CHROME_ROW_HEIGHT,
} from "@/lib/top-chrome-insets";
type TopChromeBarProps = {
showMoreBack?: boolean;
};
/** Blurred status-bar chrome with logo + account switcher. */
export function TopChromeBar() {
export function TopChromeBar({ showMoreBack = false }: TopChromeBarProps) {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
const tint = isDark ? "rgba(9, 9, 11, 0.28)" : "rgba(255, 255, 255, 0.32)";
@@ -38,7 +42,7 @@ export function TopChromeBar() {
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: tint }]}
/>
<TopChrome />
<TopChrome showMoreBack={showMoreBack} />
</View>
);
}
+10 -2
View File
@@ -54,7 +54,7 @@ export function ExpenseFormFields({
clients,
onChange,
notesLabel = "Notes",
notesPlaceholder = "Internal details or receipt OCR text",
notesPlaceholder = "Internal details",
}: ExpenseFormFieldsProps) {
const { colors } = useAppTheme();
@@ -172,7 +172,15 @@ export function ExpenseFormFields({
onValueChange: (value: boolean) => void;
}) {
return (
<View style={[styles.flagRow, { borderColor: colors.borderGlass }]}>
<View
style={[
styles.flagRow,
{
borderColor: colors.borderGlass,
backgroundColor: colors.cardGlass,
},
]}
>
<Text style={[styles.flagLabel, { color: colors.foreground }]}>
{label}
</Text>
+7 -2
View File
@@ -80,7 +80,12 @@ export function ReceiptItemSelector({
};
return (
<View style={[styles.wrap, { borderColor: colors.border }]}>
<View
style={[
styles.wrap,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<View style={styles.header}>
<View style={styles.headerCopy}>
<Text style={[styles.title, { color: colors.foreground }]}>
@@ -140,7 +145,7 @@ export function ReceiptItemSelector({
onPress={() => toggle(item.id)}
style={({ pressed }) => [
styles.item,
{ borderColor: colors.borderGlass },
{ borderColor: colors.borderGlass, backgroundColor: colors.background },
selected && { backgroundColor: colors.muted },
pressed && styles.pressed,
]}
+12 -30
View File
@@ -105,7 +105,6 @@ export function TimeClockPanel({
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
const [initialClientResolved, setInitialClientResolved] = useState(Boolean(defaultClientId));
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
@@ -210,32 +209,6 @@ export function TimeClockPanel({
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
useEffect(() => {
if (running || defaultClientId || initialClientResolved) return;
if (!prefsLoaded || clients.length === 0) return;
const preferredId =
storedLastClientId && clients.some((client) => client.id === storedLastClientId)
? storedLastClientId
: recentClientIds.find((id) => clients.some((client) => client.id === id)) ?? null;
if (preferredId) {
const client = clients.find((c) => c.id === preferredId);
setClientId(preferredId);
setRateText(clientRateText(client));
}
setInitialClientResolved(true);
}, [
clients,
defaultClientId,
initialClientResolved,
prefsLoaded,
recentClientIds,
running,
storedLastClientId,
]);
useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
@@ -332,10 +305,12 @@ export function TimeClockPanel({
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (!featuredClientIds.includes(nextClientId)) {
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
void persistClientChoice(nextClientId);
if (nextClientId) {
void persistClientChoice(nextClientId);
}
}
function selectInvoice(nextInvoiceId: string) {
@@ -596,6 +571,11 @@ export function TimeClockPanel({
) : (
<>
<View style={styles.chipWrap}>
<FilterChip
label="No client"
active={!clientId}
onPress={() => selectClient("")}
/>
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
<FilterChip
@@ -617,7 +597,9 @@ export function TimeClockPanel({
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
{!clientId ? (
<Text style={styles.emptyClients}>Pick a client to attach a draft invoice.</Text>
<Text style={styles.emptyClients}>
No invoice for now. Add a client and invoice later if this becomes billable.
</Text>
) : (
<View style={styles.chipWrap}>
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
+20 -2
View File
@@ -17,6 +17,7 @@ type ButtonProps = PressableProps & {
loading?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
style?: ViewStyle;
leftIcon?: keyof typeof Ionicons.glyphMap;
showArrow?: boolean;
};
@@ -26,6 +27,7 @@ export function Button({
variant = "primary",
disabled,
style,
leftIcon,
showArrow = false,
...props
}: ButtonProps) {
@@ -44,7 +46,11 @@ export function Button({
borderWidth: 1,
borderColor: colors.destructive,
},
ghost: { backgroundColor: "transparent" },
ghost: {
backgroundColor: colors.cardGlass,
borderWidth: 1,
borderColor: colors.borderGlass,
},
} as const;
const labelStyles = {
@@ -73,7 +79,16 @@ export function Button({
/>
) : (
<View style={styles.content}>
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
{leftIcon ? (
<Ionicons
name={leftIcon}
size={16}
color={labelStyles[variant].color}
/>
) : null}
<Text style={[styles.label, labelStyles[variant]]} numberOfLines={1}>
{title}
</Text>
{showArrow ? (
<Ionicons
name="arrow-forward"
@@ -99,7 +114,9 @@ const styles = StyleSheet.create({
content: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
minWidth: 0,
},
arrow: {
marginTop: 1,
@@ -113,5 +130,6 @@ const styles = StyleSheet.create({
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
flexShrink: 1,
},
});