Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions __tests__/components/AIAssistant.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ jest.mock("~/hooks/useAIChat");
import { useAIChat } from "~/hooks/useAIChat";
const mockUseAIChat = useAIChat as jest.Mock;

// Resolve `t(key)` against the real English bundle so the screen renders its
// actual copy (the assertions below match the English strings) without spinning
// up the full i18n runtime that `_layout` initializes.
jest.mock("react-i18next", () => {
const en = jest.requireActual("~/locales/en.json") as Record<string, unknown>;
const lookup = (key: string): unknown =>
key.split(".").reduce<unknown>(
(o, k) => (o as Record<string, unknown> | undefined)?.[k],
en,
);
return {
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
const value = lookup(key);
if (opts?.returnObjects) return Array.isArray(value) ? value : [];
if (typeof value !== "string") return key;
return opts
? value.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(opts[name] ?? ""))
: value;
},
}),
};
});

const mockSetString = jest.fn().mockResolvedValue(undefined);
jest.mock("expo-clipboard", () => ({
setStringAsync: (...args: unknown[]) => mockSetString(...args),
Expand Down
18 changes: 10 additions & 8 deletions app/(tabs)/apps.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { View, Text, ScrollView, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useCallback, useState } from "react";
import { LayoutGrid, ChevronRight } from "lucide-react-native";
import { PressableCard } from "~/components/ui/PressableCard";
Expand All @@ -13,6 +14,7 @@ import { getUserErrorMessage } from "~/lib/error-handling";
export default function AppsScreen() {
const { apps, isLoading, error, refetch } = useApps();
const router = useRouter();
const { t } = useTranslation();
const [isRefreshing, setIsRefreshing] = useState(false);

const handleAppPress = (appName: string) => {
Expand All @@ -38,11 +40,11 @@ export default function AppsScreen() {
}
>
<View className="mb-4">
<Text className="text-2xl font-bold text-foreground">Apps</Text>
<Text className="text-2xl font-bold text-foreground">{t("apps.title")}</Text>
<Text className="mt-1 text-sm text-muted-foreground">
{showSkeleton
? "Loading your apps…"
: `${apps.length} app${apps.length !== 1 ? "s" : ""} installed`}
? t("apps.loading")
: t("apps.installed", { count: apps.length })}
</Text>
</View>

Expand All @@ -53,18 +55,18 @@ export default function AppsScreen() {
<EmptyState
icon={LayoutGrid}
variant="error"
title="Unable to Load Apps"
title={t("apps.loadErrorTitle")}
description={getUserErrorMessage(error)}
actionLabel="Retry"
actionLabel={t("common.retry")}
onAction={refetch}
/>
</View>
) : apps.length === 0 ? (
<View className="pt-24">
<EmptyState
icon={LayoutGrid}
title="No Apps"
description="Your enterprise applications will appear here once installed."
title={t("apps.emptyTitle")}
description={t("apps.emptyDesc")}
/>
</View>
) : (
Expand All @@ -77,7 +79,7 @@ export default function AppsScreen() {
className="flex-row items-center p-4"
onPress={() => handleAppPress(app.name)}
accessibilityRole="button"
accessibilityLabel={`Open ${app.label}`}
accessibilityLabel={t("apps.openA11y", { name: app.label })}
>
<View className="rounded-xl bg-primary/10 p-3">
<Icon size={24} color="#1e40af" />
Expand Down
38 changes: 20 additions & 18 deletions app/(tabs)/more.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Sparkles,
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { authClient } from "~/lib/auth-client";
import { useToast } from "~/components/ui/Toast";
import { useConfirm } from "~/components/ui/ConfirmDialog";
Expand Down Expand Up @@ -55,6 +56,7 @@ function SectionHeader({ title }: { title: string }) {
export default function MoreScreen() {
const { data: session } = authClient.useSession();
const router = useRouter();
const { t } = useTranslation();
const { toastError } = useToast();
const confirm = useConfirm();

Expand All @@ -63,15 +65,15 @@ export default function MoreScreen() {
await authClient.signOut();
router.replace("/(auth)/sign-in");
} catch {
toastError("Failed to sign out. Please try again.");
toastError(t("more.signOutFailed"));
}
};

const handleSignOut = async () => {
const ok = await confirm({
title: "Sign Out",
message: "Are you sure you want to sign out?",
confirmLabel: "Sign Out",
title: t("more.signOutTitle"),
message: t("more.signOutConfirm"),
confirmLabel: t("more.signOutTitle"),
destructive: true,
});
if (ok) void performSignOut();
Expand All @@ -84,70 +86,70 @@ export default function MoreScreen() {
<TouchableOpacity
className="flex-row items-center px-5 py-5 border-b border-border/30"
onPress={() => router.push("/account")}
accessibilityLabel="View profile"
accessibilityLabel={t("more.viewProfile")}
accessibilityRole="button"
>
<View className="rounded-full bg-muted p-3">
<UserCircle size={32} color="#94a3b8" />
</View>
<View className="ml-3 flex-1">
<Text className="text-lg font-bold text-foreground">
{session?.user.name ?? "User"}
{session?.user.name ?? t("more.profileFallbackName")}
</Text>
<Text className="text-sm text-muted-foreground">
{session?.user.email ?? "View profile"}
{session?.user.email ?? t("more.viewProfile")}
</Text>
</View>
<ChevronRight size={18} color="#94a3b8" />
</TouchableOpacity>

{/* Account */}
<SectionHeader title="Account" />
<SectionHeader title={t("more.sectionAccount")} />
<MenuItem
icon={<UserCircle size={20} color="#64748b" />}
label="Account & Security"
label={t("more.accountSecurity")}
onPress={() => router.push("/account")}
/>
<MenuItem
icon={<Bell size={20} color="#64748b" />}
label="Notifications"
label={t("more.notifications")}
onPress={() => router.push("/(tabs)/notifications")}
/>

{/* Assistant */}
<SectionHeader title="Assistant" />
<SectionHeader title={t("more.sectionAssistant")} />
<MenuItem
icon={<Sparkles size={20} color="#64748b" />}
label="AI Assistant"
label={t("more.aiAssistant")}
onPress={() => router.push("/ai")}
/>

{/* Automation */}
<SectionHeader title="Automation" />
<SectionHeader title={t("more.sectionAutomation")} />
<MenuItem
icon={<Inbox size={20} color="#64748b" />}
label="Approvals"
label={t("more.approvals")}
onPress={() => router.push("/approvals")}
/>
<MenuItem
icon={<Workflow size={20} color="#64748b" />}
label="Flows"
label={t("more.flows")}
onPress={() => router.push("/flows")}
/>

{/* Preferences */}
<SectionHeader title="Preferences" />
<SectionHeader title={t("more.sectionPreferences")} />
<MenuItem
icon={<Globe size={20} color="#64748b" />}
label="Language"
label={t("more.language")}
onPress={() => router.push("/language")}
/>

{/* Sign Out */}
<View className="mt-4 border-t border-border/30">
<MenuItem
icon={<LogOut size={20} color="#dc2626" />}
label="Sign Out"
label={t("more.signOut")}
onPress={handleSignOut}
showChevron={false}
destructive
Expand Down
14 changes: 8 additions & 6 deletions app/(tabs)/notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { View, Text, ScrollView, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Bell, CheckCheck, Circle } from "lucide-react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
Expand Down Expand Up @@ -71,6 +72,7 @@ export default function NotificationsScreen() {
markAllRead,
} = useNotifications();
const router = useRouter();
const { t } = useTranslation();

const handlePress = (n: NotificationItem) => {
if (!n.read) {
Expand All @@ -87,10 +89,10 @@ export default function NotificationsScreen() {
{/* Title header */}
<View className="flex-row items-end justify-between px-5 pb-2 pt-4">
<View>
<Text className="text-2xl font-bold text-foreground">Notifications</Text>
<Text className="text-2xl font-bold text-foreground">{t("notifications.title")}</Text>
{unreadCount > 0 && (
<Text className="mt-1 text-sm text-muted-foreground">
{unreadCount} unread
{t("notifications.unread", { count: unreadCount })}
</Text>
)}
</View>
Expand All @@ -99,10 +101,10 @@ export default function NotificationsScreen() {
className="flex-row items-center gap-1.5 rounded-lg px-3 py-1.5 active:bg-muted"
onPress={() => void markAllRead()}
accessibilityRole="button"
accessibilityLabel="Mark all notifications read"
accessibilityLabel={t("notifications.markAllReadA11y")}
>
<CheckCheck size={14} color="#3b82f6" />
<Text className="text-sm font-medium text-primary">Mark all read</Text>
<Text className="text-sm font-medium text-primary">{t("notifications.markAllRead")}</Text>
</Pressable>
)}
</View>
Expand All @@ -118,8 +120,8 @@ export default function NotificationsScreen() {
{!isLoading && notifications.length === 0 && (
<EmptyState
icon={Bell}
title="No Notifications"
description="You're all caught up. New notifications will appear here."
title={t("notifications.emptyTitle")}
description={t("notifications.emptyDesc")}
/>
)}

Expand Down
Loading
Loading