import type { RoadmapItem, RoadmapStatus } from "convex-feedback"; import { useState, type PropsWithChildren } from "react"; import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FeedbackBodyProvider, useFeedbackBody, } from "../../../shared/context/FeedbackBodyProvider.js"; import { FeedbackProvider, useFeedbackUi, } from "../../../shared/context/FeedbackProvider.js"; import { kinds } from "../../../shared/helpers.js"; import type { RoadmapScreenProps } from "../../../shared/types/index.js"; import { collectNativeMetadata } from "../metadata.js"; import { Button } from "./Button.js"; import { EntryCard } from "./EntryCard.js"; import { EntryDetail } from "./EntryDetail.js"; import { RoadmapBoard, RoadmapBoardCard } from "./RoadmapBoard.js"; import { FeedbackBoard } from "./primitives.js"; const stages: readonly { value: RoadmapStatus }[] = [ { value: "planned" }, { value: "in_progress" }, { value: "shipped" }, ]; /** * Keeps the roadmap and its attached entry details on the same UI context. * Routed Expo layouts reuse this provider so every route sees the same hooks, * theme, messages, and comment configuration. */ export function RoadmapProvider({ hooks, messages, theme, unstyled, commentSort = "top", maxCommentDepth = 5, transformComments, renderActor, onUnauthenticated, children, }: PropsWithChildren< Pick< RoadmapScreenProps, | "hooks" | "messages" | "theme" | "unstyled" | "commentSort" | "maxCommentDepth" | "transformComments" | "renderActor" | "onUnauthenticated" > >) { return ( {children} ); } /** Self-contained public roadmap screen for native and non-routed Expo apps. */ export function RoadmapScreen({ hooks, messages, theme, unstyled, onEntryOpen, onUnauthenticated, commentSort, maxCommentDepth, transformComments, renderActor, pageSize = hooks.pageSizes.roadmap, entryPageSize = hooks.pageSizes.entries, ...colors }: RoadmapScreenProps) { return ( ); } export interface RoadmapScreenContentProps extends Pick< RoadmapScreenProps, | "onEntryOpen" | "onUnauthenticated" | "primaryColor" | "primaryForeground" | "backgroundColor" | "surfaceColor" | "inputColor" | "textColor" | "mutedColor" | "borderColor" | "dangerColor" > { pageSize: number; entryPageSize: number; /** Controlled search value used by Expo Router's native Stack.SearchBar. */ query?: string; /** Controlled search setter used by Expo Router's native Stack.SearchBar. */ onQueryChange?: (query: string) => void; /** Renders the inline title/search header for non-Stack usage. @default true */ showBoardHeader?: boolean; /** * Additional top space reserved for content behind a transparent native * stack header. Defaults to `0`. */ topInset?: number; /** * Total bottom space reserved for a host navigation bar or overlay. Defaults * to the bottom safe-area inset. An explicit value replaces that default, * including `0`. */ bottomInset?: number; } /** * Shared screen content used by direct native and Expo Stack integrations. * Consumers using an overlaying host toolbar should pass its full occupied * height through `bottomInset`; NativeTabs consumers normally omit it because * the tab bar supplies a bounded content region. */ export function RoadmapScreenContent({ pageSize, entryPageSize, onEntryOpen, query, onQueryChange, showBoardHeader = true, topInset, bottomInset, ...colors }: RoadmapScreenContentProps) { const [selected, setSelected] = useState(null); const { hooks } = useFeedbackBody(); const selectedQuery = hooks.useRoadmapItem(selected?.id); const authoritativeSelected = selectedQuery === undefined ? selected : selectedQuery; return ( {authoritativeSelected === null ? ( ) : ( setSelected(null)} /> )} ); } /** Board content used by both the self-contained and routed Expo screens. */ export function RoadmapBoardContent({ pageSize, onItemOpen, query: controlledQuery, onQueryChange, showHeader = true, topInset = 0, bottomInset, }: { pageSize: number; onItemOpen: (item: RoadmapItem) => void; query?: string; onQueryChange?: (query: string) => void; showHeader?: boolean; /** Additional top space reserved below a transparent native stack header. */ topInset?: number; /** Total bottom space reserved for a host navigation bar or overlay. */ bottomInset?: number; }) { const { hooks } = useFeedbackBody(); const { messages, theme } = useFeedbackUi(); const insets = useSafeAreaInsets(); const resolvedBottomInset = bottomInset ?? insets.bottom; const [internalQuery, setInternalQuery] = useState(""); const query = controlledQuery ?? internalQuery; const setQuery = onQueryChange ?? setInternalQuery; const roadmap = hooks.useRoadmap(); const search = hooks.useSearchRoadmap(query); const searching = query.trim().length > 0; const items = searching ? (search ?? []) : roadmap.results; const loading = searching ? search === undefined : roadmap.status === "LoadingFirstPage"; return ( {showHeader && ( {messages.roadmap.title} {messages.roadmap.subtitle} )} {!loading && items.length === 0 ? ( {searching ? messages.roadmap.noSearchResults : messages.roadmap.noItems} ) : ( ({ ...stage, label: messages.roadmap.statuses[stage.value], }))} emptyLabel={messages.roadmap.emptyStage} loading={loading} topInset={topInset} bottomInset={resolvedBottomInset} onItemOpen={onItemOpen} renderItem={({ item, onOpen }) => ( )} /> )} {!searching && (roadmap.status === "CanLoadMore" || roadmap.status === "LoadingMore") && (