Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
@@ -0,0 +1,180 @@
import { useMemo } from "react";
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView } from "react-native-webview";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import {
canPreviewPdfInput,
type InvoicePdfPreviewInput,
} from "@/lib/invoice-pdf-input";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type InvoicePdfPreviewProps = {
input: InvoicePdfPreviewInput | null;
height?: number;
style?: StyleProp<ViewStyle>;
};
function buildPdfHtml(contentType: string, base64: string) {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0" />
<style>
html, body { margin: 0; height: 100%; background: #525659; }
embed { width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<embed src="data:${contentType};base64,${base64}" type="application/pdf" />
</body>
</html>`;
}
export function InvoicePdfPreview({
input,
height = 560,
style,
}: InvoicePdfPreviewProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createPreviewStyles);
const enabled = canPreviewPdfInput(input);
const { data, isLoading, isFetching, error, refetch } =
api.invoices.previewPdf.useQuery(input!, {
enabled,
refetchOnWindowFocus: false,
staleTime: 5_000,
});
const html = useMemo(() => {
if (!data?.base64) return null;
return buildPdfHtml(data.contentType, data.base64);
}, [data]);
if (!enabled) {
return (
<View style={[styles.frame, { height }, style]}>
<Text style={styles.placeholder}>
Select a client and add a description to every line item to preview the
PDF.
</Text>
</View>
);
}
if (isLoading && !html) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>Generating preview</Text>
</View>
);
}
if (error) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<Text style={styles.errorText}>{error.message}</Text>
<Pressable accessibilityRole="button" onPress={() => void refetch()}>
<Text style={[styles.retry, { color: colors.primary }]}>Try again</Text>
</Pressable>
</View>
);
}
if (!html) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<Text style={styles.placeholder}>PDF preview will appear here.</Text>
</View>
);
}
return (
<View style={[styles.wrapper, style]}>
{isFetching ? (
<View style={styles.refreshing}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : null}
<View style={[styles.frame, { height }]}>
<WebView
originWhitelist={["*"]}
source={{ html }}
style={styles.webview}
scrollEnabled
showsVerticalScrollIndicator
showsHorizontalScrollIndicator={false}
/>
</View>
</View>
);
}
const createPreviewStyles = (colors: ThemeColors) =>
StyleSheet.create({
wrapper: {
gap: spacing.xs,
},
frame: {
overflow: "hidden",
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
},
webview: {
flex: 1,
backgroundColor: "transparent",
},
centered: {
alignItems: "center",
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
placeholder: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
textAlign: "center",
padding: spacing.lg,
},
loadingText: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
},
errorText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.destructive,
textAlign: "center",
},
retry: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
refreshing: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
zIndex: 2,
borderRadius: radii.pill,
backgroundColor: colors.card,
padding: spacing.xs,
},
});