Expose mobile More tools
This commit is contained in:
@@ -3,6 +3,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
|
|||||||
|
|
||||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||||
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
||||||
|
import { OnboardingGate } from "@/components/OnboardingGate";
|
||||||
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
||||||
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
@@ -73,7 +74,16 @@ export default function AppLayout() {
|
|||||||
/>
|
/>
|
||||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
|
||||||
|
md="more_horiz"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
<OnboardingGate />
|
||||||
<InvoiceReminderSync />
|
<InvoiceReminderSync />
|
||||||
<TimeClockLiveActivitySync />
|
<TimeClockLiveActivitySync />
|
||||||
<ShortcutHandler />
|
<ShortcutHandler />
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [clientName, setClientName] = useState("");
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
const createBusiness = api.businesses.create.useMutation();
|
||||||
|
const createClient = api.clients.create.useMutation();
|
||||||
|
const complete = api.settings.completeOnboarding.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
await utils.settings.getProfile.invalidate();
|
||||||
|
router.replace("/(app)");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
if (businessName.trim()) {
|
||||||
|
await createBusiness.mutateAsync({
|
||||||
|
name: businessName.trim(),
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clientName.trim()) {
|
||||||
|
await createClient.mutateAsync({
|
||||||
|
name: clientName.trim(),
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await complete.mutateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: "Welcome to beenvoice",
|
||||||
|
body: "Set up your workspace in a minute — business profile and first client.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Your business",
|
||||||
|
body: "This appears on invoices you send to clients.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "First client",
|
||||||
|
body: "Add someone you bill. You can skip and add clients later.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const current = steps[step]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text style={[styles.kicker, { color: colors.mutedForeground }]}>
|
||||||
|
Step {step + 1} of {steps.length}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{current.title}</Text>
|
||||||
|
<Text style={[styles.bodyText, { color: colors.mutedForeground }]}>{current.body}</Text>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<Input
|
||||||
|
label="Business name"
|
||||||
|
value={businessName}
|
||||||
|
onChangeText={setBusinessName}
|
||||||
|
placeholder="Your studio or company"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 2 ? (
|
||||||
|
<Input
|
||||||
|
label="Client name"
|
||||||
|
value={clientName}
|
||||||
|
onChangeText={setClientName}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{step > 0 ? (
|
||||||
|
<Button title="Back" variant="secondary" onPress={() => setStep((s) => s - 1)} />
|
||||||
|
) : null}
|
||||||
|
{step < steps.length - 1 ? (
|
||||||
|
<Button title="Continue" onPress={() => setStep((s) => s + 1)} />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
title={complete.isPending ? "Finishing…" : "Go to dashboard"}
|
||||||
|
loading={complete.isPending}
|
||||||
|
onPress={() => void finish()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{statusQuery.data && !statusQuery.data.completed ? (
|
||||||
|
<Button title="Skip for now" variant="secondary" onPress={() => void complete.mutateAsync()} />
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
minHeight: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
kicker: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
bodyText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/** Redirect new users into onboarding until they complete setup. */
|
||||||
|
export function OnboardingGate() {
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
const redirected = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirected.current || statusQuery.isLoading || !statusQuery.data) return;
|
||||||
|
if (statusQuery.data.completed) return;
|
||||||
|
redirected.current = true;
|
||||||
|
router.push("/(app)/onboarding" as never);
|
||||||
|
}, [statusQuery.data, statusQuery.isLoading]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user