Add Authentik sign-in, fix tab scroll insets, and polish multi-account auth.
Mobile app detects SSO per server, supports OAuth sign-in, and preserves saved sessions when adding accounts. Tab screens get proper chrome layout and tab-bar clearance with scrollable page headers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,7 +31,7 @@ export default function AppLayout() {
|
||||
disableTransparentOnScrollEdge
|
||||
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
|
||||
>
|
||||
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }}
|
||||
md="grid_view"
|
||||
@@ -39,7 +39,7 @@ export default function AppLayout() {
|
||||
<NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="timer" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "timer", selected: "timer" }}
|
||||
md="timer"
|
||||
@@ -47,7 +47,7 @@ export default function AppLayout() {
|
||||
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="entities" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger name="entities" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
|
||||
md="corporate_fare"
|
||||
@@ -55,7 +55,7 @@ export default function AppLayout() {
|
||||
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="invoices" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger name="invoices" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "doc.text", selected: "doc.text.fill" }}
|
||||
md="description"
|
||||
@@ -63,7 +63,7 @@ export default function AppLayout() {
|
||||
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="settings" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||
<NativeTabs.Trigger name="settings" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "gearshape", selected: "gearshape.fill" }}
|
||||
md="settings"
|
||||
|
||||
+15
-18
@@ -18,6 +18,7 @@ import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppLock } from "@/contexts/AppLockContext";
|
||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||||
@@ -251,11 +252,7 @@ export default function SettingsScreen() {
|
||||
<Button
|
||||
title="Add another account"
|
||||
variant="secondary"
|
||||
onPress={async () => {
|
||||
await authClient.signOut();
|
||||
await clearActiveAccount();
|
||||
router.replace("/(auth)/sign-in");
|
||||
}}
|
||||
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
|
||||
/>
|
||||
{accounts.length > 1 ? (
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
@@ -336,6 +333,19 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card title="App">
|
||||
<View style={styles.appRow}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
|
||||
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
|
||||
</View>
|
||||
<View style={styles.appRow}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
|
||||
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||
{Constants.platform?.ios ? "iOS" : "Other"}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: showAdvanced }}
|
||||
@@ -359,19 +369,6 @@ export default function SettingsScreen() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="App">
|
||||
<View style={styles.appRow}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
|
||||
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
|
||||
</View>
|
||||
<View style={styles.appRow}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
|
||||
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||
{Constants.platform?.ios ? "iOS" : "Other"}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} />
|
||||
</View>
|
||||
|
||||
+30
-17
@@ -20,7 +20,7 @@ import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAuthClient } from "@/contexts/AuthContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { registerAccount } from "@/lib/auth-api";
|
||||
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
|
||||
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||
import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
@@ -82,11 +82,8 @@ export default function RegisterScreen() {
|
||||
const session = await authClient.getSession();
|
||||
const user = session.data?.user;
|
||||
if (user) {
|
||||
await finalizeAuthenticatedAccount({
|
||||
await completeSignInAfterAuth(authClient, {
|
||||
apiUrl,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
activeAccountId,
|
||||
registerAccount: saveAccount,
|
||||
});
|
||||
@@ -109,15 +106,17 @@ export default function RegisterScreen() {
|
||||
contentContainerStyle={styles.container}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<AuthServerPicker onReadyChange={setServerReady} />
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Logo size="lg" />
|
||||
<HeadingText style={styles.title}>Create your account</HeadingText>
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
Get started today
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Logo size="lg" />
|
||||
<HeadingText style={styles.title}>Create your account</HeadingText>
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
Get started today
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.row}>
|
||||
@@ -190,6 +189,7 @@ export default function RegisterScreen() {
|
||||
</Link>
|
||||
</Text>
|
||||
</Card>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</FullScreen>
|
||||
@@ -203,17 +203,30 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
content: {
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
},
|
||||
card: {
|
||||
gap: spacing.lg,
|
||||
},
|
||||
header: { gap: spacing.sm },
|
||||
title: { fontSize: 24, marginTop: spacing.sm },
|
||||
header: {
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
marginTop: spacing.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
textAlign: "center",
|
||||
},
|
||||
form: { gap: spacing.md },
|
||||
row: { flexDirection: "row", gap: spacing.md },
|
||||
|
||||
+173
-69
@@ -1,5 +1,6 @@
|
||||
import { Link, router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
@@ -20,7 +21,9 @@ import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAuthClient } from "@/contexts/AuthContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
|
||||
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
|
||||
import { signInWithAuthentik } from "@/lib/auth-oauth";
|
||||
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
||||
|
||||
export default function SignInScreen() {
|
||||
@@ -32,8 +35,24 @@ export default function SignInScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverReady, setServerReady] = useState(true);
|
||||
const [authentikEnabled, setAuthentikEnabled] = useState(false);
|
||||
const [signupsDisabled, setSignupsDisabled] = useState(false);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void fetchAuthCapabilities(apiUrl).then((capabilities) => {
|
||||
if (cancelled) return;
|
||||
setAuthentikEnabled(capabilities.authentik);
|
||||
setSignupsDisabled(capabilities.signupsDisabled);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [apiUrl]);
|
||||
|
||||
const emailValidationError = !email.trim()
|
||||
? "Email is required"
|
||||
: isValidEmail(email)
|
||||
@@ -42,6 +61,18 @@ export default function SignInScreen() {
|
||||
const passwordValidationError = password.trim() ? undefined : "Password is required";
|
||||
const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady;
|
||||
|
||||
async function finishSignIn() {
|
||||
const completed = await completeSignInAfterAuth(authClient, {
|
||||
apiUrl,
|
||||
activeAccountId,
|
||||
registerAccount,
|
||||
});
|
||||
|
||||
if (!completed) {
|
||||
setError("Signed in but session was not available. Try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignIn() {
|
||||
markSubmitted();
|
||||
if (!canSignIn) return;
|
||||
@@ -64,18 +95,29 @@ export default function SignInScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await authClient.getSession();
|
||||
const user = session.data?.user;
|
||||
if (user) {
|
||||
await finalizeAuthenticatedAccount({
|
||||
apiUrl,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
activeAccountId,
|
||||
registerAccount,
|
||||
});
|
||||
await finishSignIn();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthentikSignIn() {
|
||||
if (!serverReady) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error: oauthError } = await signInWithAuthentik(
|
||||
authClient,
|
||||
Linking.createURL("/"),
|
||||
);
|
||||
|
||||
if (oauthError) {
|
||||
setError(oauthError.message ?? "Could not sign in with Authentik");
|
||||
return;
|
||||
}
|
||||
|
||||
await finishSignIn();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -92,66 +134,96 @@ export default function SignInScreen() {
|
||||
contentContainerStyle={styles.container}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<AuthServerPicker onReadyChange={setServerReady} />
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Logo size="lg" />
|
||||
<HeadingText style={styles.title}>Welcome back</HeadingText>
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
Sign in to manage invoices on the go
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
onBlur={() => touch("email")}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
error={visible("email") ? emailValidationError : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
onBlur={() => touch("password")}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
error={visible("password") ? passwordValidationError : undefined}
|
||||
/>
|
||||
|
||||
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
|
||||
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
|
||||
Forgot password?
|
||||
<View style={styles.content}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Logo size="lg" />
|
||||
<HeadingText style={styles.title}>Welcome back</HeadingText>
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
Sign in to manage invoices on the go
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||
|
||||
{signupsDisabled ? (
|
||||
<Text style={[styles.notice, { color: colors.mutedForeground }]}>
|
||||
New account registration is currently disabled on this server.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title="Sign In"
|
||||
loading={loading}
|
||||
disabled={!canSignIn}
|
||||
onPress={handleSignIn}
|
||||
/>
|
||||
</View>
|
||||
{authentikEnabled ? (
|
||||
<View style={styles.ssoSection}>
|
||||
<Button
|
||||
title="Sign in with Authentik"
|
||||
variant="secondary"
|
||||
loading={loading}
|
||||
disabled={!serverReady}
|
||||
onPress={() => void handleAuthentikSignIn()}
|
||||
/>
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerLabel, { color: colors.mutedForeground }]}>
|
||||
or
|
||||
</Text>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||
Don't have an account?{" "}
|
||||
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
|
||||
Create one
|
||||
</Link>
|
||||
</Text>
|
||||
</Card>
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
onBlur={() => touch("email")}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
error={visible("email") ? emailValidationError : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
onBlur={() => touch("password")}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
error={visible("password") ? passwordValidationError : undefined}
|
||||
/>
|
||||
|
||||
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
|
||||
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
|
||||
Forgot password?
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{error ? (
|
||||
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title="Sign In"
|
||||
loading={loading}
|
||||
disabled={!canSignIn}
|
||||
onPress={handleSignIn}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!signupsDisabled ? (
|
||||
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||
Don't have an account?{" "}
|
||||
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
|
||||
Create one
|
||||
</Link>
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</FullScreen>
|
||||
@@ -169,22 +241,54 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
content: {
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
},
|
||||
card: {
|
||||
gap: spacing.lg,
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
marginTop: spacing.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
textAlign: "center",
|
||||
},
|
||||
notice: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.body,
|
||||
textAlign: "center",
|
||||
lineHeight: 18,
|
||||
},
|
||||
ssoSection: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
dividerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
},
|
||||
dividerLabel: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
@@ -12,7 +11,8 @@ import {
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||
import { useSession } from "@/contexts/AuthContext";
|
||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatServerHost } from "@/lib/server-mode";
|
||||
|
||||
@@ -34,7 +34,6 @@ function displayName(name: string, email: string) {
|
||||
/** 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,
|
||||
@@ -56,9 +55,7 @@ export function AccountSwitcher() {
|
||||
|
||||
async function handleAddAccount() {
|
||||
setOpen(false);
|
||||
await authClient.signOut();
|
||||
await clearActiveAccount();
|
||||
router.replace("/(auth)/sign-in");
|
||||
await startAdditionalAccountSignIn(clearActiveAccount);
|
||||
}
|
||||
|
||||
async function handleSwitch(accountId: string) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
|
||||
type AuthServerPickerProps = {
|
||||
onReadyChange?: (ready: boolean) => void;
|
||||
/** When true, picker sits inside the auth card with no outer margin. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
|
||||
@@ -26,7 +28,7 @@ function modeSummary(mode: ServerMode, selfHostedUrl: string) {
|
||||
return host || "Self-hosted";
|
||||
}
|
||||
|
||||
export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
|
||||
export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServerPickerProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const { apiUrl, setInstanceUrl } = useAccounts();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -93,7 +95,7 @@ export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.wrapper, embedded && styles.wrapperEmbedded]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
@@ -182,6 +184,9 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
wrapperEmbedded: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { tabLayout } from "@/lib/tab-layout";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { useTopChromeHeight } from "@/lib/top-chrome-insets";
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
/** Title block — transparent, scrolls under TopChromeBar blur. */
|
||||
/** Title block — scrolls with tab screen content. */
|
||||
export function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const topChromeHeight = useTopChromeHeight();
|
||||
|
||||
return (
|
||||
<View style={[tabLayout.pageHeader, { paddingTop: topChromeHeight + spacing.md }]}>
|
||||
<View style={tabLayout.pageHeader}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text>
|
||||
</View>
|
||||
|
||||
+3
-11
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { TopChromeBar } from "@/components/TopChromeBar";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
@@ -10,23 +9,15 @@ type TabPageProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Tab root — floating blurred top chrome; children should be a TabScrollView. */
|
||||
/** Tab root — pinned top chrome, scrollable body below. */
|
||||
export function TabPage({ children }: TabPageProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isDark } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<StatusBar style={isDark ? "light" : "dark"} />
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
{ paddingLeft: insets.left, paddingRight: insets.right },
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
<TopChromeBar />
|
||||
<View style={styles.content}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +29,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useScrollToTop } from "expo-router";
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native";
|
||||
|
||||
import { tabLayout } from "@/lib/tab-layout";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import { useTabScreenScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
type TabScrollViewProps = ScrollViewProps & {
|
||||
/** Rendered above screen body — scrolls under the blurred top chrome. */
|
||||
/** Rendered at the top of scroll content (scrolls with the page). */
|
||||
header?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Scroll view for native tab screens — content scrolls under the tab bar. */
|
||||
/**
|
||||
* Tab screen scroll view. Top chrome (logo / account) is pinned in TabPage;
|
||||
* the page header and body scroll together here.
|
||||
*/
|
||||
export function TabScrollView({
|
||||
header,
|
||||
children,
|
||||
@@ -18,18 +22,22 @@ export function TabScrollView({
|
||||
style,
|
||||
...props
|
||||
}: TabScrollViewProps) {
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const bottomPadding = useTabScreenScrollPadding();
|
||||
|
||||
useScrollToTop(scrollRef);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={[styles.scroll, style]}
|
||||
contentContainerStyle={[
|
||||
tabLayout.scrollContent,
|
||||
{ paddingBottom: scrollPadding },
|
||||
{ paddingBottom: bottomPadding },
|
||||
contentContainerStyle,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
scrollIndicatorInsets={{ bottom: bottomPadding }}
|
||||
{...props}
|
||||
>
|
||||
{header}
|
||||
@@ -41,6 +49,7 @@ export function TabScrollView({
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,11 +45,7 @@ export function TopChromeBar() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
host: {
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Alert, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Alert, Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
@@ -11,8 +12,6 @@ import { Input } from "@/components/ui/Input";
|
||||
import { SelectField } from "@/components/ui/SelectField";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
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";
|
||||
@@ -70,7 +69,6 @@ export function TimeClockPanel({
|
||||
}, []);
|
||||
|
||||
const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart });
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
const clockIn = api.timeEntries.clockIn.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -252,11 +250,9 @@ export function TimeClockPanel({
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
<TabScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[tabLayout.scrollContent, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
header={header}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={runningQuery.isRefetching}
|
||||
@@ -270,8 +266,6 @@ export function TimeClockPanel({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{header}
|
||||
<View style={tabLayout.scrollBody}>
|
||||
{running || !compact ? (
|
||||
<GlassSurface style={running ? styles.runningCard : undefined}>
|
||||
<View style={styles.hero}>
|
||||
@@ -442,8 +436,7 @@ export function TimeClockPanel({
|
||||
})}
|
||||
</Card>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TabScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+32
-18
@@ -50,6 +50,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
|
||||
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
||||
const [biometricLabel, setBiometricLabel] = useState("Biometrics");
|
||||
const wasBackgrounded = useRef(false);
|
||||
const biometricUnlockInProgress = useRef(false);
|
||||
const hydrated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,11 +106,17 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
|
||||
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||
if (!hydrated.current || !enabled || !activeAccountId) return;
|
||||
|
||||
if (nextState === "background" || nextState === "inactive") {
|
||||
// Only true backgrounding should re-lock — `inactive` fires during Face ID,
|
||||
// Control Center, and other system sheets and must not trigger another lock.
|
||||
if (nextState === "background") {
|
||||
wasBackgrounded.current = true;
|
||||
}
|
||||
|
||||
if (nextState === "active" && wasBackgrounded.current) {
|
||||
if (
|
||||
nextState === "active" &&
|
||||
wasBackgrounded.current &&
|
||||
!biometricUnlockInProgress.current
|
||||
) {
|
||||
wasBackgrounded.current = false;
|
||||
setIsLocked(true);
|
||||
}
|
||||
@@ -125,6 +132,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
|
||||
if (!stored || stored !== pin) {
|
||||
return false;
|
||||
}
|
||||
wasBackgrounded.current = false;
|
||||
setIsLocked(false);
|
||||
return true;
|
||||
},
|
||||
@@ -136,24 +144,30 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: "Unlock beenvoice",
|
||||
cancelLabel: "Use PIN",
|
||||
disableDeviceFallback: false,
|
||||
biometricsSecurityLevel: "weak",
|
||||
});
|
||||
biometricUnlockInProgress.current = true;
|
||||
try {
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: "Unlock beenvoice",
|
||||
cancelLabel: "Use PIN",
|
||||
disableDeviceFallback: false,
|
||||
biometricsSecurityLevel: "weak",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return false;
|
||||
if (!result.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!biometricEnabled) {
|
||||
await setBiometricEnabled(activeAccountId, true);
|
||||
setBiometricEnabledState(true);
|
||||
}
|
||||
|
||||
wasBackgrounded.current = false;
|
||||
setIsLocked(false);
|
||||
return true;
|
||||
} finally {
|
||||
biometricUnlockInProgress.current = false;
|
||||
}
|
||||
|
||||
if (!biometricEnabled) {
|
||||
await setBiometricEnabled(activeAccountId, true);
|
||||
setBiometricEnabledState(true);
|
||||
}
|
||||
|
||||
setIsLocked(false);
|
||||
return true;
|
||||
}, [biometricAvailable, biometricEnabled, activeAccountId]);
|
||||
|
||||
const enableLock = useCallback(
|
||||
|
||||
+16
-11
@@ -1,5 +1,6 @@
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import {
|
||||
createContext,
|
||||
@@ -10,6 +11,20 @@ import {
|
||||
|
||||
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient {
|
||||
return createAuthClient({
|
||||
baseURL: apiUrl,
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: "beenvoice",
|
||||
storagePrefix,
|
||||
storage: SecureStore,
|
||||
}),
|
||||
genericOAuthClient(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthClient | null>(null);
|
||||
|
||||
export function AuthProvider({
|
||||
@@ -22,17 +37,7 @@ export function AuthProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
createAuthClient({
|
||||
baseURL: apiUrl,
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: "beenvoice",
|
||||
storagePrefix,
|
||||
storage: SecureStore,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
() => createAppAuthClient(apiUrl, storagePrefix),
|
||||
[apiUrl, storagePrefix],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { router } from "expo-router";
|
||||
|
||||
import { prepareForAdditionalSignIn } from "@/lib/auth-storage";
|
||||
|
||||
/** Switch to guest mode and open sign-in without wiping other saved accounts. */
|
||||
export async function startAdditionalAccountSignIn(
|
||||
clearActiveAccount: () => Promise<void>,
|
||||
) {
|
||||
await clearActiveAccount();
|
||||
await prepareForAdditionalSignIn();
|
||||
router.replace("/(auth)/sign-in");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type AuthCapabilities = {
|
||||
authentik: boolean;
|
||||
signupsDisabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_CAPABILITIES: AuthCapabilities = {
|
||||
authentik: false,
|
||||
signupsDisabled: false,
|
||||
};
|
||||
|
||||
export async function fetchAuthCapabilities(apiUrl: string): Promise<AuthCapabilities> {
|
||||
const base = apiUrl.replace(/\/$/, "");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/api/auth/capabilities`);
|
||||
if (!response.ok) return DEFAULT_CAPABILITIES;
|
||||
return (await response.json()) as AuthCapabilities;
|
||||
} catch {
|
||||
return DEFAULT_CAPABILITIES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { createAuthClient } from "better-auth/react";
|
||||
|
||||
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
type OAuth2SignIn = (input: {
|
||||
providerId: string;
|
||||
callbackURL: string;
|
||||
}) => Promise<{ error?: { message?: string } | null }>;
|
||||
|
||||
export async function signInWithAuthentik(authClient: AuthClient, callbackURL: string) {
|
||||
return (authClient.signIn as unknown as { oauth2: OAuth2SignIn }).oauth2({
|
||||
providerId: "authentik",
|
||||
callbackURL,
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,33 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearAuthStorage(prefix: string): Promise<void> {
|
||||
await Promise.all(
|
||||
AUTH_STORAGE_SUFFIXES.map(async (suffix) => {
|
||||
const key = storageKeyForPrefix(prefix, suffix);
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
|
||||
if (value?.startsWith(CHUNK_MARKER)) {
|
||||
const count = Number(value.slice(CHUNK_MARKER.length));
|
||||
if (Number.isInteger(count) && count > 0) {
|
||||
await Promise.all(
|
||||
Array.from({ length: count }, (_, index) =>
|
||||
SecureStore.deleteItemAsync(`${key}.${index}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Clears guest auth storage before signing into an additional account. */
|
||||
export async function prepareForAdditionalSignIn(): Promise<void> {
|
||||
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
|
||||
}
|
||||
|
||||
export async function finalizeAuthenticatedAccount(input: {
|
||||
apiUrl: string;
|
||||
userId: string;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { createAuthClient } from "better-auth/react";
|
||||
|
||||
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
|
||||
|
||||
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
export async function completeSignInAfterAuth(
|
||||
authClient: AuthClient,
|
||||
input: {
|
||||
apiUrl: string;
|
||||
activeAccountId: string | null;
|
||||
registerAccount: (account: {
|
||||
instanceUrl: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}) => Promise<unknown>;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const session = await authClient.getSession();
|
||||
const user = session.data?.user;
|
||||
if (!user) return false;
|
||||
|
||||
await finalizeAuthenticatedAccount({
|
||||
apiUrl: input.apiUrl,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
activeAccountId: input.activeAccountId,
|
||||
registerAccount: input.registerAccount,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -50,6 +50,17 @@ export function useFloatingActionBottom(): number {
|
||||
return tabBar + homeIndicator + spacing.xs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom padding for tab-root ScrollViews (Dashboard, Invoices, etc.).
|
||||
* Uses full tab-bar clearance — do not trim; undershooting hides content under the bar.
|
||||
*/
|
||||
export function useTabScreenScrollPadding(): number {
|
||||
const { bottom: homeIndicator } = useSafeAreaInsets();
|
||||
const tabBar = useNativeTabBarHeight();
|
||||
|
||||
return tabBar + homeIndicator + spacing.sm;
|
||||
}
|
||||
|
||||
/** @deprecated Use useTabBarScrollPadding */
|
||||
export function useTabBarInset() {
|
||||
return useTabBarScrollPadding();
|
||||
|
||||
+2
-1
@@ -6,12 +6,13 @@ import { spacing } from "@/constants/theme";
|
||||
export const tabLayout = StyleSheet.create({
|
||||
pageHeader: {
|
||||
gap: 4,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
scrollBody: {
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HStack, Image, Text } from "@expo/ui/swift-ui";
|
||||
import { HStack, Image, Spacer, Text } from "@expo/ui/swift-ui";
|
||||
import {
|
||||
font,
|
||||
foregroundStyle,
|
||||
@@ -17,8 +17,8 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
||||
|
||||
const green = "green";
|
||||
const title = props.description.trim() || "Clock In";
|
||||
const subtitle = [props.clientName, props.invoiceLabel].filter(Boolean).join(" · ");
|
||||
const detailLine = subtitle ? `${title}\n${subtitle}` : title;
|
||||
const clientLabel = props.clientName.trim() || title;
|
||||
const subtitle = props.invoiceLabel.trim();
|
||||
|
||||
const timerMods = [
|
||||
font({ design: "monospaced", weight: "bold", size: 20 }),
|
||||
@@ -34,41 +34,43 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
||||
lineLimit(1),
|
||||
minimumScaleFactor(0.8),
|
||||
];
|
||||
const brandMods = [
|
||||
const clientMods = [
|
||||
font({ weight: "semibold", size: 13 }),
|
||||
foregroundStyle(green),
|
||||
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
||||
lineLimit(1),
|
||||
minimumScaleFactor(0.85),
|
||||
];
|
||||
const detailMods = [
|
||||
font({ weight: "medium", size: 12 }),
|
||||
const subtitleMods = [
|
||||
font({ size: 11 }),
|
||||
foregroundStyle({ type: "hierarchical", style: "secondary" }),
|
||||
lineLimit(2),
|
||||
lineLimit(1),
|
||||
minimumScaleFactor(0.85),
|
||||
];
|
||||
|
||||
return {
|
||||
banner: (
|
||||
<HStack alignment="center" spacing={10} modifiers={[padding({ all: 12 })]}>
|
||||
<HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 14, vertical: 12 })]}>
|
||||
<Image
|
||||
systemName="dollarsign.circle.fill"
|
||||
color={green}
|
||||
size={22}
|
||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
||||
/>
|
||||
<Text modifiers={brandMods}>beenvoice</Text>
|
||||
<Text modifiers={clientMods}>{clientLabel}</Text>
|
||||
<Spacer minLength={12} />
|
||||
<Text modifiers={timerMods}>{props.elapsedShort}</Text>
|
||||
</HStack>
|
||||
),
|
||||
bannerSmall: (
|
||||
<HStack alignment="center" spacing={8} modifiers={[padding({ all: 10 })]}>
|
||||
<HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 12, vertical: 10 })]}>
|
||||
<Image
|
||||
systemName="dollarsign.circle.fill"
|
||||
color={green}
|
||||
size={18}
|
||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
||||
/>
|
||||
<Text modifiers={brandMods}>beenvoice</Text>
|
||||
<Text modifiers={clientMods}>{clientLabel}</Text>
|
||||
<Spacer minLength={8} />
|
||||
<Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>
|
||||
</HStack>
|
||||
),
|
||||
@@ -97,8 +99,11 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
||||
modifiers={[widgetAccentedRenderingMode("fullColor")]}
|
||||
/>
|
||||
),
|
||||
expandedCenter: <Text modifiers={clientMods}>{clientLabel}</Text>,
|
||||
expandedTrailing: <Text modifiers={timerMods}>{props.elapsedShort}</Text>,
|
||||
expandedBottom: <Text modifiers={detailMods}>{detailLine}</Text>,
|
||||
expandedBottom: (
|
||||
<Text modifiers={subtitleMods}>{subtitle || "beenvoice"}</Text>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user