diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx index 54062dfbd..2233cd688 100644 --- a/apps/mobile/src/app/automation/create.tsx +++ b/apps/mobile/src/app/automation/create.tsx @@ -9,13 +9,13 @@ import { ScrollView, View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/text"; import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; import { useSkillStoreSkill } from "@/features/tasks/skills/hooks"; import { formatSkillTemplateId } from "@/features/tasks/skills/skillTemplateIds"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; // Reserved space below the scrolling form content. Tall enough that the @@ -35,7 +35,7 @@ export default function CreateAutomationScreen() { : skillNameParam; const router = useRouter(); const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); + const { insets, bottom } = useScreenInsets(); const createAutomation = useCreateTaskAutomation(); const defaultTimezone = useMemo( () => getCalendars()[0]?.timeZone ?? "UTC", @@ -209,7 +209,7 @@ export default function CreateAutomationScreen() { {formMounted && ( ("installed"); const [search, setSearch] = useState(""); @@ -149,7 +149,7 @@ export default function McpServersScreen() { tintColor={themeColors.accent[9]} /> } - contentContainerStyle={{ paddingBottom: insets.bottom + 24 }} + contentContainerStyle={{ paddingBottom: bottom("default") }} /> ) : ( } - contentContainerStyle={{ paddingBottom: insets.bottom + 24 }} + contentContainerStyle={{ paddingBottom: bottom("default") }} /> )} diff --git a/apps/mobile/src/app/mcp-servers/installation/[id].tsx b/apps/mobile/src/app/mcp-servers/installation/[id].tsx index dcae9d321..21c5edf5b 100644 --- a/apps/mobile/src/app/mcp-servers/installation/[id].tsx +++ b/apps/mobile/src/app/mcp-servers/installation/[id].tsx @@ -16,7 +16,6 @@ import { Switch, View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader"; import { ServerIcon } from "@/features/mcp/components/ServerIcon"; import { @@ -31,6 +30,7 @@ import { reauthorizeInstallation } from "@/features/mcp/oauth"; import { getMcpConnectionManager } from "@/features/mcp/service"; import type { McpApprovalState } from "@/features/mcp/types"; import { isStdioServer } from "@/features/mcp/types"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -39,7 +39,7 @@ const log = logger.scope("mcp-installation-detail"); export default function McpInstallationDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); + const { insets, bottom } = useScreenInsets(); const installations = useMcpInstallations(); const installation = useMemo( @@ -142,7 +142,7 @@ export default function McpInstallationDetailScreen() { className="flex-1" contentContainerStyle={{ paddingTop: insets.top + 60, - paddingBottom: insets.bottom + 24, + paddingBottom: bottom("default"), paddingHorizontal: 16, }} refreshControl={ diff --git a/apps/mobile/src/app/mcp-servers/template/[id].tsx b/apps/mobile/src/app/mcp-servers/template/[id].tsx index 2f37ba11a..607a88ce6 100644 --- a/apps/mobile/src/app/mcp-servers/template/[id].tsx +++ b/apps/mobile/src/app/mcp-servers/template/[id].tsx @@ -10,7 +10,6 @@ import { TextInput, View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader"; import { ServerIcon } from "@/features/mcp/components/ServerIcon"; import { @@ -20,6 +19,7 @@ import { } from "@/features/mcp/hooks"; import { installTemplateWithOAuth } from "@/features/mcp/oauth"; import { isStdioServer } from "@/features/mcp/types"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -28,7 +28,7 @@ const log = logger.scope("mcp-template-detail"); export default function McpTemplateDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); + const { insets, bottom } = useScreenInsets(); const marketplace = useMcpMarketplace(); const installations = useMcpInstallations(); @@ -116,7 +116,7 @@ export default function McpTemplateDetailScreen() { className="flex-1" contentContainerStyle={{ paddingTop: insets.top + 60, - paddingBottom: insets.bottom + 24, + paddingBottom: bottom("default"), paddingHorizontal: 16, }} > diff --git a/apps/mobile/src/app/pr-diff.tsx b/apps/mobile/src/app/pr-diff.tsx index 37f10e8fd..bb895e606 100644 --- a/apps/mobile/src/app/pr-diff.tsx +++ b/apps/mobile/src/app/pr-diff.tsx @@ -1,18 +1,18 @@ import { Text } from "@components/text"; import { useLocalSearchParams } from "expo-router"; import { ActivityIndicator, FlatList, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FileDiff } from "@/features/tasks/components/FileDiff"; import { type ChangedFile, usePrChangedFiles, } from "@/features/tasks/hooks/usePrChangedFiles"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; export default function PrDiffScreen() { const { prUrl } = useLocalSearchParams<{ prUrl?: string }>(); const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); + const { bottom } = useScreenInsets(); const { data: files, isLoading } = usePrChangedFiles(prUrl ?? null); @@ -47,7 +47,7 @@ export default function PrDiffScreen() { contentContainerStyle={{ paddingHorizontal: 12, paddingTop: 8, - paddingBottom: insets.bottom + 16, + paddingBottom: bottom("default"), }} ListHeaderComponent={ diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index eb362d933..ce512e3e7 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -3,7 +3,6 @@ import { router } from "expo-router"; import { ArrowSquareOut, CaretRight, SpeakerHigh } from "phosphor-react-native"; import { useState } from "react"; import { Linking, Pressable, ScrollView, Switch, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAuthStore, useProjectsQuery, useUserQuery } from "@/features/auth"; import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedReportsStore"; import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore"; @@ -19,6 +18,7 @@ import { SettingsRow } from "@/features/settings/components/SettingsRow"; import { SettingsSection } from "@/features/settings/components/SettingsSection"; import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; import { playCompletionSound } from "@/features/tasks/utils/sounds"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -79,7 +79,7 @@ function taskModeLabel(mode: InitialTaskMode): string { export default function SettingsScreen() { const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); + const { insets, bottom } = useScreenInsets(); const { logout, @@ -170,7 +170,7 @@ export default function SettingsScreen() { // padding clears the home indicator and gives breathing room past the last // row so it never hides behind it. const contentPaddingTop = insets.top + 60; - const contentPaddingBottom = insets.bottom + 32; + const contentPaddingBottom = bottom("default"); return ( diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 53de78337..4e57d3753 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -12,7 +12,6 @@ import { } from "react-native"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { getTask, runTaskInCloud } from "@/features/tasks/api"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; @@ -36,6 +35,7 @@ import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { Task } from "@/features/tasks/types"; import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -59,7 +59,7 @@ export default function TaskDetailScreen() { }>(); const router = useRouter(); const queryClient = useQueryClient(); - const insets = useSafeAreaInsets(); + const { insets, composerBottom } = useScreenInsets(); const themeColors = useThemeColors(); const [task, setTask] = useState(null); const [loading, setLoading] = useState(true); @@ -123,9 +123,9 @@ export default function TaskDetailScreen() { // height, so the composer sits at the keyboard top — no extra gap needed // when open. Closed state keeps a comfortable bottom inset. return { - marginBottom: height.value < 0 ? 0 : Math.max(insets.bottom, 50), + marginBottom: height.value < 0 ? 0 : composerBottom(), }; - }, [insets.bottom]); + }, [composerBottom]); useEffect(() => { if (!taskId) return; diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 3ca3cc3fc..62aab442e 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -28,8 +28,6 @@ import { useReanimatedKeyboardAnimation, } from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { createTask, runTaskInCloud } from "@/features/tasks/api"; @@ -74,6 +72,7 @@ import { isRepositorySelectionComplete, toRepositorySelection, } from "@/features/tasks/utils/repositorySelection"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; import { toRgba, useThemeColors } from "@/lib/theme"; @@ -110,9 +109,9 @@ export default function NewTaskScreen() { }>(); const router = useRouter(); const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); + const { insets, bottom } = useScreenInsets(); const keyboard = useReanimatedKeyboardAnimation(); - const restingBottom = insets.bottom + 12; + const restingBottom = bottom("compact"); const { error, hasGithubIntegration, diff --git a/apps/mobile/src/components/SheetContainer.tsx b/apps/mobile/src/components/SheetContainer.tsx new file mode 100644 index 000000000..27a830130 --- /dev/null +++ b/apps/mobile/src/components/SheetContainer.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; +import { Modal, Pressable, View } from "react-native"; +import { + type BottomGapVariant, + useScreenInsets, +} from "@/hooks/useScreenInsets"; + +const SHEET_SHADOW = { + shadowColor: "#000", + shadowOpacity: 0.15, + shadowRadius: 20, + shadowOffset: { width: 0, height: -4 }, + elevation: 12, +} as const; + +interface SheetContainerProps { + open: boolean; + onClose: () => void; + children: ReactNode; + /** Bottom padding gap above the safe-area inset. Defaults to "compact". */ + bottomGap?: BottomGapVariant; + /** Extra classes for the sheet panel. */ + className?: string; +} + +/** + * Bottom-sheet shell: a dimmed backdrop, a panel pinned to the bottom edge with + * the standard rounded top, border, shadow, drag handle, and safe-area-aware + * bottom padding. Tapping the backdrop closes; taps inside the panel don't. + * + * Use this instead of re-implementing the `mt-auto rounded-t-2xl …` markup so + * every sheet shares one shape and one inset policy. + */ +export function SheetContainer({ + open, + onClose, + children, + bottomGap = "compact", + className = "", +}: SheetContainerProps) { + const { bottom } = useScreenInsets(); + + return ( + + + {}} + className={`mt-auto rounded-t-2xl border-gray-6 border-t bg-background ${className}`} + style={{ paddingBottom: bottom(bottomGap), ...SHEET_SHADOW }} + > + {/* Drag handle */} + + + + {children} + + + + ); +} diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx index 648f1b90c..6ca8ec8ff 100644 --- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -12,7 +12,7 @@ import { TextInput, View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { DISMISSAL_REASON_OPTIONS, @@ -35,7 +35,7 @@ export function DismissReportSheet({ onClose, onDismissed, }: DismissReportSheetProps) { - const insets = useSafeAreaInsets(); + const { insets, bottom, contentTop } = useScreenInsets(); const themeColors = useThemeColors(); const [reason, setReason] = useState(null); const [note, setNote] = useState(""); @@ -83,7 +83,7 @@ export function DismissReportSheet({ > {/* Header */} @@ -163,7 +163,7 @@ export function DismissReportSheet({ {/* Sticky submit */} {/* Header */} @@ -145,7 +145,7 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, - paddingBottom: insets.bottom + 40, + paddingBottom: bottom("roomy"), }} > {/* Sort */} diff --git a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx index 927546241..4a5c14c2e 100644 --- a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx @@ -9,8 +9,8 @@ import { ScrollView, View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUserQuery } from "@/features/auth"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; @@ -67,7 +67,7 @@ export function ReviewerFilterSheet({ visible, onClose, }: ReviewerFilterSheetProps) { - const insets = useSafeAreaInsets(); + const { bottom, contentTop } = useScreenInsets(); const themeColors = useThemeColors(); const { data: currentUser } = useUserQuery(); const { data: available, isLoading } = useAvailableSuggestedReviewers(); @@ -98,7 +98,7 @@ export function ReviewerFilterSheet({ > {/* Header */} @@ -132,7 +132,7 @@ export function ReviewerFilterSheet({ contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 12, - paddingBottom: insets.bottom + 40, + paddingBottom: bottom("roomy"), }} > {options.map((reviewer, index) => { diff --git a/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx b/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx index 3c5afb5b2..ce73885f6 100644 --- a/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx +++ b/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx @@ -1,7 +1,7 @@ import { Plus } from "phosphor-react-native"; import { Pressable } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/text"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; interface FloatingNewAutomationButtonProps { @@ -15,7 +15,7 @@ interface FloatingNewAutomationButtonProps { export function FloatingNewAutomationButton({ onPress, }: FloatingNewAutomationButtonProps) { - const insets = useSafeAreaInsets(); + const { fabBottom } = useScreenInsets(); const themeColors = useThemeColors(); return ( @@ -26,7 +26,7 @@ export function FloatingNewAutomationButton({ accessibilityRole="button" className="absolute right-5 z-10 h-14 flex-row items-center justify-center gap-2 rounded-full bg-accent-9 pr-5 pl-4 active:opacity-85" style={{ - bottom: insets.bottom + 20, + bottom: fabBottom(), shadowColor: "#000", shadowOpacity: 0.22, shadowRadius: 12, diff --git a/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx index a4b40ac97..8907c3d15 100644 --- a/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx +++ b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx @@ -1,7 +1,7 @@ import { Plus } from "phosphor-react-native"; import { Pressable } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/text"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; interface FloatingNewTaskButtonProps { @@ -13,7 +13,7 @@ interface FloatingNewTaskButtonProps { * on phones of any size and respects the home indicator inset. */ export function FloatingNewTaskButton({ onPress }: FloatingNewTaskButtonProps) { - const insets = useSafeAreaInsets(); + const { fabBottom } = useScreenInsets(); const themeColors = useThemeColors(); return ( @@ -24,7 +24,7 @@ export function FloatingNewTaskButton({ onPress }: FloatingNewTaskButtonProps) { accessibilityRole="button" className="absolute right-5 z-10 h-14 flex-row items-center justify-center gap-2 rounded-full bg-accent-9 pr-5 pl-4 active:opacity-85" style={{ - bottom: insets.bottom + 20, + bottom: fabBottom(), shadowColor: "#000", shadowOpacity: 0.22, shadowRadius: 12, diff --git a/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx index 81c0d8bde..97b87f447 100644 --- a/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx +++ b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx @@ -2,8 +2,8 @@ import { Text } from "@components/text"; import { Check, FunnelSimple } from "phosphor-react-native"; import { useState } from "react"; import { Modal, Pressable, ScrollView, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUserQuery } from "@/features/auth"; +import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { type OrganizeMode, @@ -44,7 +44,7 @@ function OptionRow({ label, selected, onPress }: OptionRowProps) { } export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) { - const insets = useSafeAreaInsets(); + const { bottom, contentTop } = useScreenInsets(); const organizeMode = useTaskStore((s) => s.organizeMode); const setOrganizeMode = useTaskStore((s) => s.setOrganizeMode); const sortMode = useTaskStore((s) => s.sortMode); @@ -70,7 +70,7 @@ export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) { > {/* Header */} @@ -88,7 +88,7 @@ export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) { contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, - paddingBottom: insets.bottom + 40, + paddingBottom: bottom("roomy"), }} > {/* Organize */} diff --git a/apps/mobile/src/features/tasks/composer/SelectSheet.tsx b/apps/mobile/src/features/tasks/composer/SelectSheet.tsx index a1932f7a6..2fb6daf18 100644 --- a/apps/mobile/src/features/tasks/composer/SelectSheet.tsx +++ b/apps/mobile/src/features/tasks/composer/SelectSheet.tsx @@ -1,8 +1,8 @@ import { Text } from "@components/text"; import { Check } from "phosphor-react-native"; import type { ReactNode } from "react"; -import { Modal, Pressable, ScrollView, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Pressable, ScrollView, View } from "react-native"; +import { SheetContainer } from "@/components/SheetContainer"; import { useThemeColors } from "@/lib/theme"; export interface SelectOption { @@ -31,84 +31,51 @@ export function SelectSheet({ onClose, }: SelectSheetProps) { const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); return ( - - - {}} - className="mt-auto rounded-t-2xl border-gray-6 border-t bg-background" - style={{ - paddingBottom: insets.bottom + 12, - shadowColor: "#000", - shadowOpacity: 0.15, - shadowRadius: 20, - shadowOffset: { width: 0, height: -4 }, - elevation: 12, - }} - > - {/* Drag handle */} - - - + + + {title} + - - - {title} - - - - - {options.map((option) => { - const selected = option.value === value; - return ( - { - if (option.disabled) return; - onChange(option.value); - onClose(); - }} - disabled={option.disabled} - className={`flex-row items-center gap-3 px-4 py-3 ${ - option.disabled ? "opacity-40" : "active:bg-gray-2" - }`} - > - {option.icon ? ( - - {option.icon} - - ) : null} - - - {option.label} - - {option.description ? ( - - {option.description} - - ) : null} - - {selected ? ( - - ) : null} - - ); - })} - - - - + + {options.map((option) => { + const selected = option.value === value; + return ( + { + if (option.disabled) return; + onChange(option.value); + onClose(); + }} + disabled={option.disabled} + className={`flex-row items-center gap-3 px-4 py-3 ${ + option.disabled ? "opacity-40" : "active:bg-gray-2" + }`} + > + {option.icon ? ( + + {option.icon} + + ) : null} + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + {selected ? ( + + ) : null} + + ); + })} + + ); } diff --git a/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx b/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx index e64a6b387..6bfb44bad 100644 --- a/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx +++ b/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx @@ -1,7 +1,7 @@ import { Text } from "@components/text"; import { Camera, FileText, Image as ImageIcon } from "phosphor-react-native"; -import { Modal, Pressable, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Pressable, View } from "react-native"; +import { SheetContainer } from "@/components/SheetContainer"; import { useThemeColors } from "@/lib/theme"; interface AttachmentSheetProps { @@ -44,74 +44,44 @@ export function AttachmentSheet({ onPickDocument, }: AttachmentSheetProps) { const themeColors = useThemeColors(); - const insets = useSafeAreaInsets(); return ( - - - {}} - className="mt-auto rounded-t-2xl border-gray-6 border-t bg-background" - style={{ - paddingBottom: insets.bottom + 12, - shadowColor: "#000", - shadowOpacity: 0.15, - shadowRadius: 20, - shadowOffset: { width: 0, height: -4 }, - elevation: 12, - }} - > - - - - - - - Add attachment - - + + + + Add attachment + + - - } - label="Photo library" - description="Pick a photo to share with the agent" - onPress={() => { - onClose(); - onPickPhoto(); - }} - /> - - } - label="Take photo" - description="Capture a new photo from the camera" - onPress={() => { - onClose(); - onPickCamera(); - }} - /> - - } - label="File" - description="Attach a text or code file from your device" - onPress={() => { - onClose(); - onPickDocument(); - }} - /> - - - + + } + label="Photo library" + description="Pick a photo to share with the agent" + onPress={() => { + onClose(); + onPickPhoto(); + }} + /> + } + label="Take photo" + description="Capture a new photo from the camera" + onPress={() => { + onClose(); + onPickCamera(); + }} + /> + } + label="File" + description="Attach a text or code file from your device" + onPress={() => { + onClose(); + onPickDocument(); + }} + /> + ); } diff --git a/apps/mobile/src/hooks/useScreenInsets.ts b/apps/mobile/src/hooks/useScreenInsets.ts new file mode 100644 index 000000000..081fabf6b --- /dev/null +++ b/apps/mobile/src/hooks/useScreenInsets.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +/** + * Single source of truth for screen spacing policy. + * + * The device safe-area insets (notch, home indicator, Android soft buttons) + * come from `useSafeAreaInsets()` and vary per device — we never hardcode + * those. What this hook centralizes is the *gap we add on top of the inset*, + * which was previously hand-written per screen with a scatter of magic numbers + * (12 / 16 / 20 / 24 / 32 / 40 / 50). Those are rationalized into one small + * scale so spacing is consistent across surfaces and tunable in one place. + */ + +/** + * Standard content gaps layered ON TOP of the bottom safe-area inset. + * Pick by intent, not by pixel value. + */ +export const BOTTOM_GAP = { + /** Bottom sheets, sticky footers, tight composers. */ + compact: 12, + /** Scrollable form / list content in modals and detail screens. */ + default: 24, + /** Filter sheets and sections that want extra breathing room. */ + roomy: 40, +} as const; + +/** Standard gap above the top safe-area inset for page-sheet content. */ +const TOP_GAP = 8; + +/** Bottom-right floating action buttons sit this far above the inset. */ +const FAB_GAP = 20; + +/** + * The chat composer keeps at least this much bottom space when the keyboard + * is closed, even on devices that report a zero bottom inset (e.g. older + * Android with hardware buttons). + */ +export const COMPOSER_MIN_BOTTOM = 50; + +export type BottomGapVariant = keyof typeof BOTTOM_GAP; + +export function useScreenInsets() { + const insets = useSafeAreaInsets(); + + return useMemo(() => { + return { + /** Raw device insets, for cases the helpers below don't cover. */ + insets, + /** Bottom padding = device inset + the standard gap for this surface. */ + bottom: (variant: BottomGapVariant = "default") => + insets.bottom + BOTTOM_GAP[variant], + /** Top padding for page-sheet content (inset + standard top gap). */ + contentTop: () => insets.top + TOP_GAP, + /** Bottom offset for a floating action button. */ + fabBottom: () => insets.bottom + FAB_GAP, + /** Composer bottom margin floor (never smaller than the min). */ + composerBottom: () => Math.max(insets.bottom, COMPOSER_MIN_BOTTOM), + }; + }, [insets]); +}