Polish mobile app for App Store review and expand CRUD.
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+21
-13
@@ -1,25 +1,22 @@
|
||||
import { DynamicColorIOS, Platform } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { mutedForeground, primary } from "@/lib/beenvoice-theme";
|
||||
import { AppLockProvider } from "@/contexts/AppLockContext";
|
||||
|
||||
export default function AppLayout() {
|
||||
const { colors } = useAppTheme();
|
||||
const { colors, isDark } = useAppTheme();
|
||||
|
||||
const tintColor =
|
||||
const tintColor = colors.primary;
|
||||
const labelColor = colors.mutedForeground;
|
||||
const tabContentStyle = { backgroundColor: colors.background };
|
||||
const tabBarBlur =
|
||||
Platform.OS === "ios"
|
||||
? DynamicColorIOS({ light: primary, dark: "#FAFAFA" })
|
||||
: colors.primary;
|
||||
|
||||
const labelColor =
|
||||
Platform.OS === "ios"
|
||||
? DynamicColorIOS({ light: mutedForeground, dark: "#A1A1AA" })
|
||||
: colors.mutedForeground;
|
||||
|
||||
const tabContentStyle = { backgroundColor: "transparent" as const };
|
||||
? isDark
|
||||
? "systemChromeMaterialDark"
|
||||
: "systemChromeMaterialLight"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<AppLockProvider>
|
||||
@@ -30,6 +27,9 @@ export default function AppLayout() {
|
||||
selected: tintColor,
|
||||
}}
|
||||
labelStyle={{ color: labelColor }}
|
||||
blurEffect={tabBarBlur}
|
||||
disableTransparentOnScrollEdge
|
||||
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
|
||||
>
|
||||
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger.Icon
|
||||
@@ -47,6 +47,14 @@ export default function AppLayout() {
|
||||
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="entities" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
|
||||
md="corporate_fare"
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="invoices" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "doc.text", selected: "doc.text.fill" }}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export default function EntitiesLayout() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: colors.cardGlass },
|
||||
headerTitleStyle: {
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 18,
|
||||
color: colors.foreground,
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: colors.foreground,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Entities",
|
||||
headerShown: false,
|
||||
statusBarTranslucent: true,
|
||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/new"
|
||||
options={{
|
||||
title: "New client",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/[id]"
|
||||
options={{
|
||||
title: "Client",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/edit/[id]"
|
||||
options={{
|
||||
title: "Edit client",
|
||||
headerBackTitle: "Client",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/new"
|
||||
options={{
|
||||
title: "New business",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/[id]"
|
||||
options={{
|
||||
title: "Business",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/edit/[id]"
|
||||
options={{
|
||||
title: "Edit business",
|
||||
headerBackTitle: "Business",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function BusinessDetailScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createBusinessDetailStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const businessQuery = api.businesses.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
const setDefault = api.businesses.setDefault.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.businesses.getAll.invalidate();
|
||||
if (id) void utils.businesses.getById.invalidate({ id });
|
||||
Alert.alert("Default updated", "This business is now your default.");
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not set default", err.message),
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid business" />;
|
||||
}
|
||||
|
||||
if (businessQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading business…" />;
|
||||
}
|
||||
|
||||
const business = businessQuery.data;
|
||||
if (!business) {
|
||||
return <LoadingScreen message="Business not found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? <Text style={styles.badge}>Default</Text> : null}
|
||||
</View>
|
||||
{business.nickname ? <Text style={styles.meta}>{business.nickname}</Text> : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
{business.phone ? <Text style={styles.meta}>{business.phone}</Text> : null}
|
||||
{business.website ? <Text style={styles.meta}>{business.website}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Card title="Details">
|
||||
{business.taxId ? (
|
||||
<DetailRow label="Tax ID" value={business.taxId} />
|
||||
) : null}
|
||||
<DetailRow
|
||||
label="Email sending"
|
||||
value={business.resendDomain ? "Configured" : "Not configured"}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{(business.addressLine1 || business.city || business.state) && (
|
||||
<Card title="Address">
|
||||
{business.addressLine1 ? (
|
||||
<Text style={styles.body}>{business.addressLine1}</Text>
|
||||
) : null}
|
||||
{business.addressLine2 ? (
|
||||
<Text style={styles.body}>{business.addressLine2}</Text>
|
||||
) : null}
|
||||
{(business.city || business.state || business.postalCode) && (
|
||||
<Text style={styles.body}>
|
||||
{[business.city, business.state, business.postalCode].filter(Boolean).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
{business.country ? <Text style={styles.body}>{business.country}</Text> : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title="Edit business"
|
||||
onPress={() => router.push(`/(app)/entities/businesses/edit/${business.id}`)}
|
||||
/>
|
||||
{!business.isDefault ? (
|
||||
<Button
|
||||
title="Set as default"
|
||||
variant="secondary"
|
||||
loading={setDefault.isPending}
|
||||
onPress={() => setDefault.mutate({ id: business.id })}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={detailStyles.row}>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detailStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
const createBusinessDetailStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
badge: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.primary,
|
||||
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
body: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.foreground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function EditBusinessScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Business" }} />
|
||||
<BusinessForm
|
||||
mode="edit"
|
||||
businessId={id}
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Saved", "Business updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
Alert.alert("Deleted", "Business removed", [
|
||||
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function NewBusinessScreen() {
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<BusinessForm
|
||||
mode="create"
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Business created", "Your business has been saved.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function ClientDetailScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createClientDetailStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
const clientQuery = api.clients.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid client" />;
|
||||
}
|
||||
|
||||
if (clientQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading client…" />;
|
||||
}
|
||||
|
||||
const client = clientQuery.data;
|
||||
if (!client) {
|
||||
return <LoadingScreen message="Client not found" />;
|
||||
}
|
||||
|
||||
const invoices = client.invoices ?? [];
|
||||
const totalInvoiced = invoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||
const currency = client.currency ?? "USD";
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.name}>{client.name}</Text>
|
||||
{client.email ? <Text style={styles.meta}>{client.email}</Text> : null}
|
||||
{client.phone ? <Text style={styles.meta}>{client.phone}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Card title="Summary">
|
||||
<DetailRow label="Total invoiced" value={formatCurrency(totalInvoiced, currency)} />
|
||||
<DetailRow label="Invoices" value={String(invoices.length)} />
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<DetailRow
|
||||
label="Default rate"
|
||||
value={`${formatCurrency(client.defaultHourlyRate, currency)}/hr`}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{(client.addressLine1 || client.city || client.state) && (
|
||||
<Card title="Address">
|
||||
{client.addressLine1 ? <Text style={styles.body}>{client.addressLine1}</Text> : null}
|
||||
{client.addressLine2 ? <Text style={styles.body}>{client.addressLine2}</Text> : null}
|
||||
{(client.city || client.state || client.postalCode) && (
|
||||
<Text style={styles.body}>
|
||||
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
{client.country ? <Text style={styles.body}>{client.country}</Text> : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Invoices">
|
||||
{invoices.length === 0 ? (
|
||||
<Text style={styles.muted}>No invoices for this client yet.</Text>
|
||||
) : (
|
||||
invoices.map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
return (
|
||||
<Pressable
|
||||
key={invoice.id}
|
||||
style={styles.invoiceRow}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
>
|
||||
<View style={styles.invoiceMeta}>
|
||||
<Text style={styles.invoiceTitle}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.muted}>Due {formatDate(invoice.dueDate)}</Text>
|
||||
</View>
|
||||
<View style={styles.invoiceRight}>
|
||||
<Text style={styles.invoiceAmount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title="Edit client"
|
||||
onPress={() => router.push(`/(app)/entities/clients/edit/${client.id}`)}
|
||||
/>
|
||||
<Button
|
||||
title="New invoice"
|
||||
variant="secondary"
|
||||
onPress={() => router.push("/(app)/invoices/new")}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={detailStyles.row}>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detailStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
const createClientDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
body: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.foreground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
muted: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
invoiceRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
invoiceMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
invoiceTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 15,
|
||||
},
|
||||
invoiceRight: {
|
||||
alignItems: "flex-end",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
invoiceAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 15,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { ClientForm } from "@/components/clients/ClientForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function EditClientScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Client" }} />
|
||||
<ClientForm
|
||||
mode="edit"
|
||||
clientId={id}
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Saved", "Client updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
Alert.alert("Deleted", "Client removed", [
|
||||
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { ClientForm } from "@/components/clients/ClientForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function NewClientScreen() {
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ClientForm
|
||||
mode="create"
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Client created", "Your client has been saved.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type EntityTab = "clients" | "businesses";
|
||||
|
||||
const tabs: Array<{ label: string; value: EntityTab }> = [
|
||||
{ label: "Clients", value: "clients" },
|
||||
{ label: "Businesses", value: "businesses" },
|
||||
];
|
||||
|
||||
export default function EntitiesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createEntitiesStyles);
|
||||
const [tab, setTab] = useState<EntityTab>("clients");
|
||||
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
|
||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||
const isLoading =
|
||||
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
}
|
||||
|
||||
if (activeQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load {tab}</Text>
|
||||
<Text style={styles.errorText}>{activeQuery.error.message}</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const clients = clientsQuery.data ?? [];
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
|
||||
function refresh() {
|
||||
if (tab === "clients") void clientsQuery.refetch();
|
||||
else void businessesQuery.refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader
|
||||
title="Entities"
|
||||
subtitle="Clients you bill and businesses you send from"
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={activeQuery.isRefetching}
|
||||
onRefresh={refresh}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabScroll}
|
||||
contentContainerStyle={styles.tabs}
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<FilterChip
|
||||
key={item.value}
|
||||
label={item.label}
|
||||
active={tab === item.value}
|
||||
onPress={() => setTab(item.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{tab === "clients" ? (
|
||||
clients.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No clients yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Add a client to start creating invoices.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
clients.map((client) => (
|
||||
<Pressable
|
||||
key={client.id}
|
||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<Text style={styles.name}>{client.name}</Text>
|
||||
{client.email ? (
|
||||
<Text style={styles.meta}>{client.email}</Text>
|
||||
) : null}
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<Text style={styles.meta}>
|
||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||
/hr
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
))
|
||||
)
|
||||
) : businesses.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No businesses yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Add your business profile for invoices and email sending.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
businesses.map((business) => (
|
||||
<Pressable
|
||||
key={business.id}
|
||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
<Text style={styles.badge}>Default</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
) : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</TabScrollView>
|
||||
<FloatingActionButton
|
||||
accessibilityLabel={tab === "clients" ? "Add client" : "Add business"}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
tab === "clients"
|
||||
? "/(app)/entities/clients/new"
|
||||
: "/(app)/entities/businesses/new",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
tabScroll: {
|
||||
flexGrow: 0,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
tabs: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md,
|
||||
},
|
||||
card: {},
|
||||
cardInner: {
|
||||
padding: spacing.md,
|
||||
gap: 4,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
badge: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.primary,
|
||||
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
+40
-25
@@ -128,40 +128,50 @@ export default function DashboardScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.statsGrid}>
|
||||
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||
<StatCard
|
||||
label="Overdue"
|
||||
value={String(stats.overdueCount)}
|
||||
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
|
||||
/>
|
||||
<StatCard label="Clients" value={String(stats.totalClients)} hint={revenueChange} />
|
||||
<View style={styles.statCell}>
|
||||
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||
</View>
|
||||
<View style={styles.statCell}>
|
||||
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||
</View>
|
||||
<View style={styles.statCell}>
|
||||
<StatCard
|
||||
label="Overdue"
|
||||
value={String(stats.overdueCount)}
|
||||
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
|
||||
/>
|
||||
</View>
|
||||
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
|
||||
<StatCard
|
||||
label="Clients"
|
||||
value={String(stats.totalClients)}
|
||||
hint={revenueChange}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Card title="Revenue (6 months)">
|
||||
<View style={styles.chart}>
|
||||
{stats.revenueChartData.map((point) => (
|
||||
<View key={point.month} style={styles.chartColumn}>
|
||||
<View style={styles.chartBarTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.chartBar,
|
||||
{ height: `${Math.max(8, (point.revenue / maxRevenue) * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
{stats.revenueChartData.map((point) => {
|
||||
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
|
||||
return (
|
||||
<View key={point.month} style={styles.chartColumn}>
|
||||
<View style={styles.chartBarTrack}>
|
||||
<View style={[styles.chartBar, { height: barHeight }]} />
|
||||
</View>
|
||||
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
|
||||
<Text style={styles.chartValue}>
|
||||
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
|
||||
<Text style={styles.chartValue}>
|
||||
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card title="Recent invoices">
|
||||
{stats.recentInvoices.length === 0 ? (
|
||||
<Text style={styles.empty}>No invoices yet. Create one on the web app.</Text>
|
||||
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
|
||||
) : (
|
||||
stats.recentInvoices.map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
@@ -262,12 +272,17 @@ const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.md,
|
||||
alignContent: "flex-start",
|
||||
},
|
||||
statCell: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
flexBasis: "47%",
|
||||
},
|
||||
chart: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.xs,
|
||||
minHeight: 140,
|
||||
},
|
||||
chartColumn: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
@@ -116,6 +116,25 @@ export default function InvoiceDetailScreen() {
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerBackTitle: "Invoices",
|
||||
headerRight: () =>
|
||||
status !== "paid" ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={8}
|
||||
onPress={promptSendInvoice}
|
||||
disabled={sendInvoice.isPending}
|
||||
style={({ pressed }) => pressed && styles.headerPressed}
|
||||
>
|
||||
<Text style={[styles.headerAction, { color: colors.primary }]}>
|
||||
{status === "draft" ? "Send" : "Resend"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
@@ -376,6 +395,13 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerAction: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 16,
|
||||
},
|
||||
headerPressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -23,13 +23,33 @@ export default function InvoicesLayout() {
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Invoices",
|
||||
headerShown: false,
|
||||
statusBarTranslucent: true,
|
||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="[id]" options={{ title: "Invoice" }} />
|
||||
<Stack.Screen name="edit/[id]" options={{ title: "Edit" }} />
|
||||
<Stack.Screen
|
||||
name="new"
|
||||
options={{
|
||||
title: "New invoice",
|
||||
headerBackTitle: "Invoices",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[id]"
|
||||
options={{
|
||||
title: "Invoice",
|
||||
headerBackTitle: "Invoices",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit/[id]"
|
||||
options={{
|
||||
title: "Edit invoice",
|
||||
headerBackTitle: "Invoice",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||
@@ -21,8 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
@@ -32,8 +32,6 @@ export default function InvoiceEditScreen() {
|
||||
const styles = useThemedStyles(createInvoiceEditStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const utils = api.useUtils();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useNativeTabBarHeight();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
@@ -46,7 +44,6 @@ export default function InvoiceEditScreen() {
|
||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const invoice = invoiceQuery.data;
|
||||
@@ -77,6 +74,16 @@ export default function InvoiceEditScreen() {
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
const sendInvoice = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
Alert.alert("Invoice sent", data.message);
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||
});
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
|
||||
const subtotal = useMemo(
|
||||
@@ -106,6 +113,31 @@ export default function InvoiceEditScreen() {
|
||||
return <LoadingScreen message="Invoice not found" />;
|
||||
}
|
||||
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
|
||||
function promptSendInvoice() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client on the web app before sending invoices.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
status === "draft" ? "Send invoice" : "Resend invoice",
|
||||
`Email this invoice to ${clientEmail}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Send",
|
||||
onPress: () => sendInvoice.mutate({ invoiceId: invoice!.id }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
@@ -180,17 +212,15 @@ export default function InvoiceEditScreen() {
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding + footerHeight },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
@@ -245,20 +275,19 @@ export default function InvoiceEditScreen() {
|
||||
</Card>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
|
||||
style={[
|
||||
styles.footer,
|
||||
{
|
||||
bottom: tabBarHeight,
|
||||
paddingBottom: Math.max(insets.bottom, spacing.sm),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||
{status !== "paid" ? (
|
||||
<Button
|
||||
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
||||
variant="secondary"
|
||||
onPress={promptSendInvoice}
|
||||
loading={sendInvoice.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
@@ -321,58 +350,51 @@ const totalStyles = StyleSheet.create({
|
||||
|
||||
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 6,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
});
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 6,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
@@ -127,7 +128,7 @@ export default function InvoicesScreen() {
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No invoices found</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Create invoices on the web app, then view and edit them here.
|
||||
Tap + to create your first invoice, or pull to refresh.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
@@ -166,6 +167,10 @@ export default function InvoicesScreen() {
|
||||
})
|
||||
)}
|
||||
</TabScrollView>
|
||||
<FloatingActionButton
|
||||
accessibilityLabel="Create invoice"
|
||||
onPress={() => router.push("/(app)/invoices/new")}
|
||||
/>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { SelectField } from "@/components/ui/SelectField";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function NewInvoiceScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
||||
const [issueDate, setIssueDate] = useState(() => new Date());
|
||||
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
||||
const [notes, setNotes] = useState("");
|
||||
const [taxRate, setTaxRate] = useState("0");
|
||||
const [items, setItems] = useState<EditableLineItem[]>([
|
||||
{
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: "0",
|
||||
},
|
||||
]);
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const clientOptions = useMemo(
|
||||
() =>
|
||||
(clientsQuery.data ?? []).map((client) => ({
|
||||
label: client.name,
|
||||
value: client.id,
|
||||
})),
|
||||
[clientsQuery.data],
|
||||
);
|
||||
|
||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||
const currency = selectedClient?.currency ?? "USD";
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedClient?.defaultHourlyRate) return;
|
||||
setItems((prev) =>
|
||||
prev.map((item, index) =>
|
||||
index === 0 && (item.rate === "0" || item.rate === "")
|
||||
? { ...item, rate: String(selectedClient.defaultHourlyRate) }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}, [selectedClient?.defaultHourlyRate, selectedClient?.id]);
|
||||
|
||||
const createInvoice = api.invoices.create.useMutation({
|
||||
onSuccess: (invoice) => {
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
Alert.alert("Invoice created", "Your draft invoice is ready.", [
|
||||
{
|
||||
text: "View invoice",
|
||||
onPress: () => router.replace(`/(app)/invoices/${invoice.id}`),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
const subtotal = useMemo(
|
||||
() =>
|
||||
items.reduce((sum, item) => {
|
||||
const hours = Number(item.hours) || 0;
|
||||
const rate = Number(item.rate) || 0;
|
||||
return sum + hours * rate;
|
||||
}, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const parsedTaxRate = Number(taxRate) || 0;
|
||||
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
if (clientsQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
}
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const nextIndex = items.length;
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
},
|
||||
]);
|
||||
setExpandedIndex(nextIndex);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
if (items.length <= 1) {
|
||||
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
|
||||
return;
|
||||
}
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
setExpandedIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (current === index) return null;
|
||||
return current > index ? current - 1 : current;
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
setError(null);
|
||||
|
||||
if (!clientId) {
|
||||
setError("Select a client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invoiceNumber.trim()) {
|
||||
setError("Invoice number is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedItems: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const hours = Number(item.hours);
|
||||
const rate = Number(item.rate);
|
||||
if (!item.description.trim()) {
|
||||
setError("Each line needs a description");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(hours) || hours < 0) {
|
||||
setError("Hours must be a valid number");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(rate) || rate < 0) {
|
||||
setError("Rate must be a valid number");
|
||||
return;
|
||||
}
|
||||
parsedItems.push({
|
||||
date: item.date,
|
||||
description: item.description.trim(),
|
||||
hours,
|
||||
rate,
|
||||
});
|
||||
}
|
||||
|
||||
const tax = Number(taxRate);
|
||||
if (Number.isNaN(tax) || tax < 0 || tax > 100) {
|
||||
setError("Tax rate must be between 0 and 100");
|
||||
return;
|
||||
}
|
||||
|
||||
createInvoice.mutate({
|
||||
clientId,
|
||||
invoiceNumber: invoiceNumber.trim(),
|
||||
issueDate,
|
||||
dueDate,
|
||||
notes,
|
||||
taxRate: tax,
|
||||
currency,
|
||||
items: parsedItems,
|
||||
status: "draft",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card title="Details">
|
||||
{clientOptions.length === 0 ? (
|
||||
<View style={styles.noClients}>
|
||||
<Text style={styles.noClientsText}>
|
||||
Add a client before creating an invoice.
|
||||
</Text>
|
||||
<Button
|
||||
title="Add client"
|
||||
variant="secondary"
|
||||
onPress={() => router.push("/(app)/entities/clients/new")}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<SelectField
|
||||
label="Client"
|
||||
placeholder="Select client…"
|
||||
value={clientId}
|
||||
options={clientOptions}
|
||||
onValueChange={setClientId}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label="Invoice number"
|
||||
value={invoiceNumber}
|
||||
onChangeText={setInvoiceNumber}
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
<DateTimeField
|
||||
label="Issue date"
|
||||
mode="date"
|
||||
value={issueDate}
|
||||
onChange={(date) => {
|
||||
setIssueDate(date);
|
||||
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
|
||||
}}
|
||||
/>
|
||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
||||
<Input
|
||||
label="Tax rate (%)"
|
||||
value={taxRate}
|
||||
onChangeText={setTaxRate}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
<Input
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Optional notes for the client"
|
||||
multiline
|
||||
style={styles.notesInput}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Line items">
|
||||
{items.map((item, index) => (
|
||||
<LineItemEditor
|
||||
key={`new-${index}`}
|
||||
item={item}
|
||||
currency={currency}
|
||||
expanded={expandedIndex === index}
|
||||
onToggle={() =>
|
||||
setExpandedIndex((current) => (current === index ? null : index))
|
||||
}
|
||||
onChange={(patch) => updateItem(index, patch)}
|
||||
onRemove={() => removeItem(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Text style={styles.addLineText}>+ Add line</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.totals}>
|
||||
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
|
||||
{parsedTaxRate > 0 ? (
|
||||
<TotalRow
|
||||
label={`Tax (${parsedTaxRate}%)`}
|
||||
value={formatCurrency(taxAmount, currency)}
|
||||
/>
|
||||
) : null}
|
||||
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title="Create invoice"
|
||||
loading={createInvoice.isPending}
|
||||
disabled={clientOptions.length === 0}
|
||||
onPress={handleCreate}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalRow({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={totalStyles.row}>
|
||||
<Text
|
||||
style={[
|
||||
totalStyles.label,
|
||||
{ color: colors.mutedForeground },
|
||||
bold && totalStyles.bold,
|
||||
bold && { color: colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
totalStyles.value,
|
||||
{ color: colors.foreground },
|
||||
bold && totalStyles.bold,
|
||||
]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const totalStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 14,
|
||||
},
|
||||
bold: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
noClients: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
noClientsText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.mutedForeground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
gap: 6,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
+42
-8
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import Constants from "expo-constants";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from "react-native";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
@@ -38,6 +39,11 @@ export default function SettingsScreen() {
|
||||
clearActiveAccount,
|
||||
} = useAccounts();
|
||||
const { colors, colorMode, setColorMode } = useAppTheme();
|
||||
const switchProps = {
|
||||
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
||||
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
|
||||
ios_backgroundColor: colors.switchIosBackground,
|
||||
};
|
||||
const {
|
||||
enabled: lockEnabled,
|
||||
biometricEnabled,
|
||||
@@ -59,6 +65,7 @@ export default function SettingsScreen() {
|
||||
| null
|
||||
>(null);
|
||||
const [pendingPin, setPendingPin] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut();
|
||||
@@ -268,7 +275,7 @@ export default function SettingsScreen() {
|
||||
<Switch
|
||||
value={lockEnabled}
|
||||
onValueChange={handleLockToggle}
|
||||
trackColor={{ false: colors.border, true: colors.primary }}
|
||||
{...switchProps}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -285,7 +292,7 @@ export default function SettingsScreen() {
|
||||
<Switch
|
||||
value={biometricEnabled}
|
||||
onValueChange={handleBiometricToggle}
|
||||
trackColor={{ false: colors.border, true: colors.primary }}
|
||||
{...switchProps}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -329,12 +336,28 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card title="Server instance">
|
||||
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||||
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
|
||||
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||||
</Text>
|
||||
</Card>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: showAdvanced }}
|
||||
onPress={() => setShowAdvanced((open) => !open)}
|
||||
style={styles.advancedToggle}
|
||||
>
|
||||
<Text style={[styles.advancedLabel, { color: colors.mutedForeground }]}>Advanced</Text>
|
||||
<Ionicons
|
||||
name={showAdvanced ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color={colors.mutedForeground}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{showAdvanced ? (
|
||||
<Card title="Server instance">
|
||||
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||||
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
|
||||
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="App">
|
||||
<View style={styles.appRow}>
|
||||
@@ -375,6 +398,17 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.mono,
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
minHeight: 36,
|
||||
},
|
||||
advancedLabel: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
appRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
|
||||
Reference in New Issue
Block a user