diff --git a/apps/expo/app/(app)/(tabs)/(home)/index.tsx b/apps/expo/app/(app)/(tabs)/(home)/index.tsx index 8874b086b7..7f3ff93736 100644 --- a/apps/expo/app/(app)/(tabs)/(home)/index.tsx +++ b/apps/expo/app/(app)/(tabs)/(home)/index.tsx @@ -2,16 +2,12 @@ import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import { arrayIncludes, assertIsString, objectKeys } from '@packrat/guards'; -import type { LargeTitleSearchBarMethods, ListDataItem } from '@packrat/ui/nativewindui'; -import { - LargeTitleHeader, - List, - type ListRenderItemInfo, - ListSectionHeader, -} from '@packrat/ui/nativewindui'; +import type { ListDataItem } from '@packrat/ui/nativewindui'; +import { List, type ListRenderItemInfo, ListSectionHeader } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { SearchOverlay } from '@packrat/ui/src/search-overlay'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; -import { LargeTitleHeaderSearchContentContainer } from 'expo-app/components/LargeTitleHeaderSearchContentContainer'; import { appConfig, featureFlags } from 'expo-app/config'; import { AIChatTile } from 'expo-app/features/ai/components/AIChatTile'; import { ReportedContentTile } from 'expo-app/features/ai/components/ReportedContentTile'; @@ -38,8 +34,7 @@ import { WeatherTile } from 'expo-app/features/weather/components/WeatherTile'; import { WildlifeTile } from 'expo-app/features/wildlife/components/WildlifeTile'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; -import { useRouter } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { useIsFocused } from 'expo-router/react-navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { FlatList, Platform, Pressable, Text, View } from 'react-native'; @@ -165,14 +160,12 @@ const DASHBOARD_GAP_PREFIX = appConfig.dashboard.gapPrefix; export default function DashboardScreen() { const [searchValue, setSearchValue] = useState(''); - const searchBarRef = useRef(null); const unlockSheetRef = useRef(null); const { t } = useTranslation(); const router = useRouter(); const isFocused = useIsFocused(); const { hasMinimumItems } = useHasMinimumInventory(20); const { announcementSeen } = useSeasonSuggestionsPrefs(); - useEffect(() => { if (!isFocused || !hasMinimumItems || announcementSeen) return; const timer = setTimeout(() => { @@ -249,71 +242,68 @@ export default function DashboardScreen() { return ( <> - - {searchValue ? ( - { - assertIsString(item); - if (!item.startsWith(DASHBOARD_GAP_PREFIX) && arrayIncludes(TILE_NAMES, item)) { - const Component = tileInfo[item].component; - return ( - { - setSearchValue(''); - searchBarRef.current?.clearText(); - }} - > - - - ); - } - return null; - }} - ListHeaderComponent={() => - filteredTiles.length > 0 ? ( - - {filteredTiles.length}{' '} - {filteredTiles.length === 1 - ? appConfig.dashboard.strings.resultSingular - : appConfig.dashboard.strings.resultPlural} - - ) : null - } - ListEmptyComponent={() => ( - - - - - {t('dashboard.noResults')} - - - {t('dashboard.tryDifferent')} - - - )} - /> - ) : ( - - {t('dashboard.searchPlaceholder')} - - )} - - ), + + + {searchValue ? ( + { + assertIsString(item); + if (!item.startsWith(DASHBOARD_GAP_PREFIX) && arrayIncludes(TILE_NAMES, item)) { + const Component = tileInfo[item].component; + return ( + setSearchValue('')} + > + + + ); + } + return null; + }} + ListHeaderComponent={() => + filteredTiles.length > 0 ? ( + + {filteredTiles.length}{' '} + {filteredTiles.length === 1 + ? appConfig.dashboard.strings.resultSingular + : appConfig.dashboard.strings.resultPlural} + + ) : null + } + ListEmptyComponent={() => ( + + + + + {t('dashboard.noResults')} + + + {t('dashboard.tryDifferent')} + + + )} + /> + ) : ( + + {t('dashboard.searchPlaceholder')} + + )} + ( + + + + + + ), } as const; // Generate display data based on user information @@ -114,18 +122,6 @@ function Profile() { <> - ( - - - - - - )} - /> - - - + - } + , + }} /> - + diff --git a/apps/expo/app/(app)/messages/conversations.android.tsx b/apps/expo/app/(app)/messages/conversations.android.tsx index 742d7bfa52..45508379e1 100644 --- a/apps/expo/app/(app)/messages/conversations.android.tsx +++ b/apps/expo/app/(app)/messages/conversations.android.tsx @@ -1,6 +1,5 @@ import { assertDefined } from '@packrat/guards'; import { - AdaptiveSearchHeader, Avatar, AvatarFallback, Button, @@ -20,7 +19,7 @@ import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import * as Haptics from 'expo-haptics'; -import { router } from 'expo-router'; +import { router, Stack } from 'expo-router'; import * as React from 'react'; import { Dimensions, Platform, Pressable, ScrollView, View, type ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; @@ -59,16 +58,18 @@ export default function ConversationsAndroidScreen() { return ( <> - + , -}; - function SearchBarContent() { const { colors } = useColorScheme(); return ( diff --git a/apps/expo/app/(app)/messages/conversations.tsx b/apps/expo/app/(app)/messages/conversations.tsx index f554b6af61..3ba8e60484 100644 --- a/apps/expo/app/(app)/messages/conversations.tsx +++ b/apps/expo/app/(app)/messages/conversations.tsx @@ -8,18 +8,18 @@ import { createContextItem, createDropdownItem, DropdownMenu, - LargeTitleHeader, List, ListItem, type ListRenderItemInfo, Text, Toolbar, } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import * as Haptics from 'expo-haptics'; -import { router } from 'expo-router'; +import { router, Stack } from 'expo-router'; import * as React from 'react'; import { Dimensions, @@ -45,7 +45,6 @@ import Animated, { } from 'react-native-reanimated'; export default function ConversationsIosScreen() { - const { colors, isDarkColorScheme } = useColorScheme(); const [isSelecting, setIsSelecting] = React.useState(false); const isSelectingDerived = useDerivedValue(() => isSelecting); const [selectedMessages, setSelectedMessages] = React.useState([]); @@ -80,13 +79,20 @@ export default function ConversationsIosScreen() { return ( <> - } - rightView={rightView} - backgroundColor={isDarkColorScheme ? colors.background : colors.card} - searchBar={SEARCH_BAR} + ( + + ), + headerRight: rightView, + headerSearchBarOptions: { + hideWhenScrolling: true, + }, + }} /> + - ), -}; + ); +} const CONTEXT_MENU_ITEMS = [ createContextItem({ diff --git a/apps/expo/app/(app)/pack-categories/[id].tsx b/apps/expo/app/(app)/pack-categories/[id].tsx index 4f8f97b771..eef4ee3aef 100644 --- a/apps/expo/app/(app)/pack-categories/[id].tsx +++ b/apps/expo/app/(app)/pack-categories/[id].tsx @@ -1,11 +1,12 @@ -import { LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; import { userStore } from 'expo-app/features/auth/store'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import { computeCategorySummaries } from 'expo-app/features/packs/utils'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { useLocalSearchParams } from 'expo-router'; +import { Stack, useLocalSearchParams } from 'expo-router'; import { ScrollView, View } from 'react-native'; function CategoryCard({ @@ -63,7 +64,7 @@ export default function PackCategoriesScreen() { return ( <> - + {categories.length ? ( diff --git a/apps/expo/app/(app)/pack-stats/[id].tsx b/apps/expo/app/(app)/pack-stats/[id].tsx index 834927ee70..35cf84af06 100644 --- a/apps/expo/app/(app)/pack-stats/[id].tsx +++ b/apps/expo/app/(app)/pack-stats/[id].tsx @@ -1,11 +1,12 @@ -import { Button, LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { Button, Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { featureFlags } from 'expo-app/config'; import { userStore } from 'expo-app/features/auth/store'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import { usePackWeightHistory } from 'expo-app/features/packs/hooks/usePackWeightHistory'; import { computeCategorySummaries } from 'expo-app/features/packs/utils'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -33,7 +34,9 @@ export default function PackStatsScreen() { return ( - + {/* Weight History Section */} diff --git a/apps/expo/app/(app)/recent-packs.tsx b/apps/expo/app/(app)/recent-packs.tsx index f58da2a2fa..b07537dd7e 100644 --- a/apps/expo/app/(app)/recent-packs.tsx +++ b/apps/expo/app/(app)/recent-packs.tsx @@ -1,10 +1,12 @@ -import { LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { Icon } from 'expo-app/components/Icon'; import type { Pack } from 'expo-app/features/packs'; import { useRecentPacks } from 'expo-app/features/packs/hooks/useRecentPacks'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { getRelativeTime } from 'expo-app/lib/utils/getRelativeTime'; +import { Stack } from 'expo-router'; import { Image, Platform, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -65,7 +67,7 @@ export default function RecentPacksScreen() { return ( - + {recentPacks.length ? ( diff --git a/apps/expo/app/(app)/season-suggestions-results.tsx b/apps/expo/app/(app)/season-suggestions-results.tsx index 2101e240ac..ed3c123d01 100644 --- a/apps/expo/app/(app)/season-suggestions-results.tsx +++ b/apps/expo/app/(app)/season-suggestions-results.tsx @@ -1,4 +1,5 @@ -import { LargeTitleHeader, Text, useColorScheme } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import * as Burnt from 'burnt'; import { Icon } from 'expo-app/components/Icon'; import { PackItemImage } from 'expo-app/features/packs/components/PackItemImage'; @@ -8,9 +9,10 @@ import { SeasonSuggestionsError, useSeasonSuggestions, } from 'expo-app/features/packs/hooks/useSeasonSuggestions'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { LinearGradient } from 'expo-linear-gradient'; -import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; import { ScrollView, @@ -376,7 +378,7 @@ export default function SeasonSuggestionsResultsScreen() { return ( <> - + diff --git a/apps/expo/app/(app)/season-suggestions.tsx b/apps/expo/app/(app)/season-suggestions.tsx index f109b5f419..b2cc57ce44 100644 --- a/apps/expo/app/(app)/season-suggestions.tsx +++ b/apps/expo/app/(app)/season-suggestions.tsx @@ -1,6 +1,7 @@ import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import { assertDefined } from '@packrat/guards'; -import { Button, LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { Button, Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import * as Sentry from '@sentry/react-native'; import { Icon } from 'expo-app/components/Icon'; import { LocationSearchSheet } from 'expo-app/features/packs/components/LocationSearchSheet'; @@ -8,7 +9,7 @@ import { LocationSourceSheet } from 'expo-app/features/packs/components/Location import { useBottomSheetAction } from 'expo-app/lib/hooks/useBottomSheetAction'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import * as Location from 'expo-location'; -import { useRouter } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { useRef, useState } from 'react'; import { ActivityIndicator, Alert, Linking, Platform, ScrollView, View } from 'react-native'; @@ -112,7 +113,7 @@ export default function SeasonSuggestionsScreen() { return ( <> - + diff --git a/apps/expo/app/(app)/shared-packs.tsx b/apps/expo/app/(app)/shared-packs.tsx index 76ab6cf6ed..f90e653b1d 100644 --- a/apps/expo/app/(app)/shared-packs.tsx +++ b/apps/expo/app/(app)/shared-packs.tsx @@ -1,12 +1,8 @@ -import { - Avatar, - AvatarFallback, - AvatarImage, - LargeTitleHeader, - Text, -} from '@packrat/ui/nativewindui'; +import { Avatar, AvatarFallback, AvatarImage, Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { Stack } from 'expo-router'; import { ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -181,7 +177,7 @@ export default function SharedPacksScreen() { const { t } = useTranslation(); return ( - + diff --git a/apps/expo/app/(app)/shopping-list.tsx b/apps/expo/app/(app)/shopping-list.tsx index 931afdd7e4..8b2fb77988 100644 --- a/apps/expo/app/(app)/shopping-list.tsx +++ b/apps/expo/app/(app)/shopping-list.tsx @@ -1,11 +1,13 @@ 'use client'; -import { LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import type { TranslationKeys } from 'expo-app/lib/i18n/types'; +import { Stack } from 'expo-router'; import { useState } from 'react'; import { Pressable, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -170,7 +172,7 @@ export default function ShoppingListScreen() { return ( - + diff --git a/apps/expo/app/(app)/trail-conditions.tsx b/apps/expo/app/(app)/trail-conditions.tsx index 984a7cf2f3..441185aca7 100644 --- a/apps/expo/app/(app)/trail-conditions.tsx +++ b/apps/expo/app/(app)/trail-conditions.tsx @@ -1,10 +1,12 @@ -import { ActivityIndicator, LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { ActivityIndicator, Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { featureFlags } from 'expo-app/config'; import { SubmitConditionReportForm } from 'expo-app/features/trail-conditions/components/SubmitConditionReportForm'; import { TrailConditionReportCard } from 'expo-app/features/trail-conditions/components/TrailConditionReportCard'; import { useTrailConditionReports } from 'expo-app/features/trail-conditions/hooks/useTrailConditionReports'; import type { TrailConditionReport, TrailSurface } from 'expo-app/features/trail-conditions/types'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { Stack } from 'expo-router'; import { useMemo, useState } from 'react'; import { FlatList, Modal, Pressable, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -160,20 +162,23 @@ export default function TrailConditionsScreen() { return ( - ( - setShowSubmitForm(true)} - className="mr-2 rounded-full bg-primary px-3 py-1.5" - accessibilityLabel={t('trailConditions.reportConditionsTitle')} - accessibilityRole="button" - > - - {t('trailConditions.reportButton')} - - - )} + ( + setShowSubmitForm(true)} + className="mr-2 rounded-full bg-primary px-3 py-1.5" + accessibilityLabel={t('trailConditions.reportConditionsTitle')} + accessibilityRole="button" + > + + {t('trailConditions.reportButton')} + + + ), + }} /> diff --git a/apps/expo/app/(app)/weather-alert-preferences.tsx b/apps/expo/app/(app)/weather-alert-preferences.tsx index 1f58119699..fd06fcaeb0 100644 --- a/apps/expo/app/(app)/weather-alert-preferences.tsx +++ b/apps/expo/app/(app)/weather-alert-preferences.tsx @@ -1,14 +1,9 @@ -import { - Form, - FormItem, - FormSection, - LargeTitleHeader, - Text, - Toggle, -} from '@packrat/ui/nativewindui'; +import { Form, FormItem, FormSection, Text, Toggle } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { Stack } from 'expo-router'; import * as React from 'react'; import { ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -71,7 +66,9 @@ export default function WeatherAlertPreferencesScreen() { return ( <> - + - + - + { - const { colors } = useColorScheme(); - - const Container = Platform.OS === 'ios' ? SafeAreaView : View; - - return {children}; -}; diff --git a/apps/expo/components/Markdown.tsx b/apps/expo/components/Markdown.tsx index af0e8bc9e4..7c1159c71e 100644 --- a/apps/expo/components/Markdown.tsx +++ b/apps/expo/components/Markdown.tsx @@ -1,5 +1,5 @@ -import { useColorScheme } from '@packrat/ui/nativewindui'; import RNMarkdown from '@ronradtke/react-native-markdown-display'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; export function Markdown({ children }: { children: string }) { const { colors } = useColorScheme(); diff --git a/apps/expo/components/initial/ExpandableText.tsx b/apps/expo/components/initial/ExpandableText.tsx index bc567829a8..ea742a3e1d 100644 --- a/apps/expo/components/initial/ExpandableText.tsx +++ b/apps/expo/components/initial/ExpandableText.tsx @@ -1,4 +1,4 @@ -import { cn } from '@packrat/ui/nativewindui'; +import { cn } from 'expo-app/lib/cn'; import { useState } from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; diff --git a/apps/expo/features/ai-packs/screens/AIPacksScreen.tsx b/apps/expo/features/ai-packs/screens/AIPacksScreen.tsx index ba14f45d2b..55b4a5df80 100644 --- a/apps/expo/features/ai-packs/screens/AIPacksScreen.tsx +++ b/apps/expo/features/ai-packs/screens/AIPacksScreen.tsx @@ -3,9 +3,9 @@ import { Alert, type AlertMethods, Button, - LargeTitleHeader, Text, } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import * as Sentry from '@sentry/react-native'; import { useForm } from '@tanstack/react-form'; import { Icon } from 'expo-app/components/Icon'; @@ -13,7 +13,7 @@ import { TextInput } from 'expo-app/components/TextInput'; import { PackCard } from 'expo-app/features/packs/components/PackCard'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { useRouter } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { useRef, useState } from 'react'; import { Modal, Platform, ScrollView, TouchableOpacity, View } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -92,8 +92,7 @@ export function AIPacksScreen() { return ( - - + - + { diff --git a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx index 6ad31e4719..149fa39ef3 100644 --- a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx @@ -1,19 +1,18 @@ -import { LargeTitleHeader, type LargeTitleSearchBarMethods, Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { LargeTitleHeaderOverlapFixIOS } from '@packrat/ui/src/large-title-header-overlap-fix-ios'; +import { SearchOverlay } from '@packrat/ui/src/search-overlay'; import { catalogGroupVariantsAtom } from 'expo-app/atoms/catalogGroupAtom'; import { searchValueAtom } from 'expo-app/atoms/itemListAtoms'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { CategoriesFilter } from 'expo-app/components/CategoriesFilter'; import { Icon } from 'expo-app/components/Icon'; -import { LargeTitleHeaderOverlapFixIOS } from 'expo-app/components/LargeTitleHeaderOverlapFixIOS'; -import { LargeTitleHeaderSearchContentContainer } from 'expo-app/components/LargeTitleHeaderSearchContentContainer'; import { withAuthWall } from 'expo-app/features/auth/hocs'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { testIds } from 'expo-app/lib/testIds'; -import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; -import { useRouter } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { useAtom, useSetAtom } from 'jotai'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -40,7 +39,6 @@ function CatalogItemsScreen() { const [isManualRefresh, setIsManualRefresh] = useState(false); const [debouncedSearchValue] = useDebounce(searchValue, 400); - const searchBarRef = useRef(null); const isSearching = searchValue.trim().length > 0; const trimmedQuery = debouncedSearchValue.trim(); @@ -124,81 +122,74 @@ function CatalogItemsScreen() { return ( <> - - {isSearching ? ( - isVectorLoading || !isQueryReady ? ( - - - - ) : ( - - - {searchResults.length > 0 && ( - - {searchResults.length} {t('catalog.results')} - - )} - + + + {!isSearching ? ( + + {t('catalog.searchCatalog')} + + ) : isVectorLoading || !isQueryReady ? ( + + + + ) : ( + + + {searchResults.length > 0 && ( + + {searchResults.length} {t('catalog.results')} + + )} + - {searchResults.map((item: CatalogItem) => ( - - handleItemPress(item)} /> - - ))} + {searchResults.map((item: CatalogItem) => ( + + handleItemPress(item)} /> + + ))} - {searchResults.length === 0 && ( - - {vectorError ? ( - <> - - - - - {t('catalog.searchError')} - - - {t('catalog.unableToSearch')} - - - ) : ( - <> - - - - - {t('catalog.noResults')} - - - {t('catalog.tryAdjustingFilters')} - - - )} - - )} - - ) + {searchResults.length === 0 && ( + + {vectorError ? ( + <> + + + + + {t('catalog.searchError')} + + + {t('catalog.unableToSearch')} + + ) : ( - - {t('catalog.searchCatalog')} - + <> + + + + + {t('catalog.noResults')} + + + {t('catalog.tryAdjustingFilters')} + + )} - - ), - } as React.ComponentProps['searchBar'] - } - /> + + )} + + )} + { return ( - ( - - - - )} + ( + + + + ), + }} /> {isLoading ? ( diff --git a/apps/expo/features/guides/screens/GuidesListScreen.tsx b/apps/expo/features/guides/screens/GuidesListScreen.tsx index 9322bd8d08..931c0b6dc2 100644 --- a/apps/expo/features/guides/screens/GuidesListScreen.tsx +++ b/apps/expo/features/guides/screens/GuidesListScreen.tsx @@ -1,12 +1,12 @@ -import { LargeTitleHeader, type LargeTitleSearchBarMethods, Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { LargeTitleHeaderOverlapFixIOS } from '@packrat/ui/src/large-title-header-overlap-fix-ios'; +import { SearchOverlay } from '@packrat/ui/src/search-overlay'; import { CategoriesFilter } from 'expo-app/components/CategoriesFilter'; -import { LargeTitleHeaderOverlapFixIOS } from 'expo-app/components/LargeTitleHeaderOverlapFixIOS'; -import { LargeTitleHeaderSearchContentContainer } from 'expo-app/components/LargeTitleHeaderSearchContentContainer'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; -import { useRouter } from 'expo-router'; -import { useCallback, useRef, useState } from 'react'; +import { Stack, useRouter } from 'expo-router'; +import { useCallback, useState } from 'react'; import { ActivityIndicator, FlatList, RefreshControl, View } from 'react-native'; import { GuideCard } from '../components/GuideCard'; import { useGuideCategories, useGuides, useSearchGuides } from '../hooks'; @@ -20,8 +20,6 @@ export const GuidesListScreen = () => { const [selectedCategory, setSelectedCategory] = useState(() => t('guides.all')); const [isManualRefresh, setIsManualRefresh] = useState(false); - const searchBarRef = useRef(null); - const { data: categories, error: categoriesError, @@ -199,20 +197,19 @@ export const GuidesListScreen = () => { return ( <> - - {renderSearchContent()} - - ), + + + {renderSearchContent()} + (null); - const searchBarRef = useRef(null); - // Filter options with translations const filterOptions: FilterOption[] = [ { label: t('packTemplates.all'), value: 'all' }, @@ -186,27 +183,25 @@ export function PackTemplateListScreen() { return ( - - {renderSearchContent()} - + ( + templateOptionsRef.current?.present()} /> ), }} - rightView={() => ( - - templateOptionsRef.current?.present()} /> - - )} /> + templateOptionsRef.current?.present()} /> + } + > + {renderSearchContent()} + ) : ( - + No packs found for "{searchValue}" ); diff --git a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx index 103bbfb672..02b8c94505 100644 --- a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx +++ b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx @@ -1,8 +1,9 @@ import { isDefined } from '@packrat/guards'; -import { ActivityIndicator, Button, Text, useColorScheme } from '@packrat/ui/nativewindui'; +import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; import { Chip } from 'expo-app/components/initial/Chip'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { calculateTotalWeight, diff --git a/apps/expo/features/packs/screens/PackListScreen.tsx b/apps/expo/features/packs/screens/PackListScreen.tsx index 3e22be8b08..8f2fa28acc 100644 --- a/apps/expo/features/packs/screens/PackListScreen.tsx +++ b/apps/expo/features/packs/screens/PackListScreen.tsx @@ -1,14 +1,9 @@ -import type { LargeTitleSearchBarMethods } from '@packrat/ui/nativewindui'; -import { - ActivityIndicator, - Button, - LargeTitleHeader, - SegmentedControl, -} from '@packrat/ui/nativewindui'; +import { ActivityIndicator, Button, SegmentedControl } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { LargeTitleHeaderOverlapFixIOS } from '@packrat/ui/src/large-title-header-overlap-fix-ios'; +import { SearchOverlay } from '@packrat/ui/src/search-overlay'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; -import { LargeTitleHeaderOverlapFixIOS } from 'expo-app/components/LargeTitleHeaderOverlapFixIOS'; -import { LargeTitleHeaderSearchContentContainer } from 'expo-app/components/LargeTitleHeaderSearchContentContainer'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { PackCard } from 'expo-app/features/packs/components/PackCard'; import { SearchResults } from 'expo-app/features/packs/components/SearchResults'; @@ -17,10 +12,9 @@ import { activeFilterAtom, searchValueAtom } from 'expo-app/features/packs/packL import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; -import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; -import { Link, useLocalSearchParams, useRouter } from 'expo-router'; +import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useAtom } from 'jotai'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { FlatList, Pressable, @@ -73,8 +67,6 @@ export function PackListScreen() { ); const allPacksQuery = useAllPacks(selectedTypeIndex === ALL_PACKS_INDEX); - const searchBarRef = useRef(null); - const { colors } = useColorScheme(); const filterOptions: FilterOption[] = [ @@ -193,35 +185,34 @@ export function PackListScreen() { return ( - - - {searchValue ? ( - - ) : ( - - {t('packs.searchPacks')} - - )} - - ), - }} - rightView={() => } - /> + , + }} + /> + } + > + {searchValue ? ( + + ) : ( + + {t('packs.searchPacks')} + + )} + + pack.id} diff --git a/apps/expo/features/packs/utils/getPackDetailOptions.tsx b/apps/expo/features/packs/utils/getPackDetailOptions.tsx index 8da15b9269..8c085dbe23 100644 --- a/apps/expo/features/packs/utils/getPackDetailOptions.tsx +++ b/apps/expo/features/packs/utils/getPackDetailOptions.tsx @@ -1,6 +1,7 @@ -import { Button, useColorScheme, useSheetRef } from '@packrat/ui/nativewindui'; +import { Button, useSheetRef } from '@packrat/ui/nativewindui'; import { appAlert } from 'expo-app/app/_layout'; import { Icon } from 'expo-app/components/Icon'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { t } from 'expo-app/lib/i18n'; import { testIds } from 'expo-app/lib/testIds'; import { useRouter } from 'expo-router'; diff --git a/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx b/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx index 6f166f2862..2023216e9e 100644 --- a/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx +++ b/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx @@ -1,6 +1,7 @@ import { assertDefined } from '@packrat/guards'; -import { Alert, Button, useColorScheme } from '@packrat/ui/nativewindui'; +import { Alert, Button } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { t } from 'expo-app/lib/i18n'; import { useRouter } from 'expo-router'; import { View } from 'react-native'; diff --git a/apps/expo/features/trips/components/UpcomingTripsTile.tsx b/apps/expo/features/trips/components/UpcomingTripsTile.tsx index 00de15c86e..9f3ed359df 100644 --- a/apps/expo/features/trips/components/UpcomingTripsTile.tsx +++ b/apps/expo/features/trips/components/UpcomingTripsTile.tsx @@ -1,8 +1,9 @@ import type { AlertMethods } from '@packrat/ui/nativewindui'; -import { Alert, ListItem, Text, useColorScheme } from '@packrat/ui/nativewindui'; +import { Alert, ListItem, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; import { featureFlags } from 'expo-app/config'; import { useTrips } from 'expo-app/features/trips/hooks'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { parseLocalDate } from 'expo-app/lib/utils/dateUtils'; import { useRouter } from 'expo-router'; diff --git a/apps/expo/features/trips/screens/TripListScreen.tsx b/apps/expo/features/trips/screens/TripListScreen.tsx index 669d8ac6e3..7cf21b84f1 100644 --- a/apps/expo/features/trips/screens/TripListScreen.tsx +++ b/apps/expo/features/trips/screens/TripListScreen.tsx @@ -1,13 +1,12 @@ -import { LargeTitleHeader, type LargeTitleSearchBarMethods } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { SearchOverlay } from '@packrat/ui/src/search-overlay'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; -import { LargeTitleHeaderSearchContentContainer } from 'expo-app/components/LargeTitleHeaderSearchContentContainer'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; -import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; -import { Link, useRouter } from 'expo-router'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { Link, Stack, useRouter } from 'expo-router'; +import { useCallback, useMemo, useState } from 'react'; import { FlatList, Pressable, ScrollView, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { TripCard } from '../components/TripCard'; @@ -57,7 +56,6 @@ export function TripsListScreen() { const router = useRouter(); const { t } = useTranslation(); const trips = useTrips(); - const searchBarRef = useRef(null); const [searchValue, setSearchValue] = useState(''); const filteredTrips = useMemo(() => { @@ -155,22 +153,22 @@ export function TripsListScreen() { return ( - - {renderSearchContent()} - - ), + , }} - rightView={() => } /> + } + > + {renderSearchContent()} + (null); const { removeLocation } = useLocations(); - // Determine if we're loading const isLoading = locationsState.state === 'loading'; @@ -119,13 +120,16 @@ function LocationsScreen() { return ( - ( - - - - )} + ( + + + + ), + }} /> diff --git a/apps/expo/features/wildlife/screens/WildlifeScreen.tsx b/apps/expo/features/wildlife/screens/WildlifeScreen.tsx index c48d234e17..9daafe94fb 100644 --- a/apps/expo/features/wildlife/screens/WildlifeScreen.tsx +++ b/apps/expo/features/wildlife/screens/WildlifeScreen.tsx @@ -1,8 +1,9 @@ -import { LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/nativewindui'; +import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { useRouter } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { ActivityIndicator, FlatList, Pressable, SafeAreaView, View } from 'react-native'; import { useWildlifeHistory } from '../hooks/useWildlifeHistory'; import type { WildlifeIdentification } from '../types'; @@ -65,7 +66,13 @@ export function WildlifeScreen() { return ( - + {/* Identify FAB */} diff --git a/biome.json b/biome.json index 5715215a55..7a88839a87 100644 --- a/biome.json +++ b/biome.json @@ -61,6 +61,16 @@ } }, "overrides": [ + { + "includes": ["packages/ui/nativewindui/index.ts"], + "assist": { + "actions": { + "source": { + "organizeImports": "off" + } + } + } + }, { "includes": [ "apps/expo/atoms/atomWith*.ts", diff --git a/docs/ideation/2026-06-13-nativewindui-to-expo-ui-ideation.md b/docs/ideation/2026-06-13-nativewindui-to-expo-ui-ideation.md new file mode 100644 index 0000000000..dd4db936d8 --- /dev/null +++ b/docs/ideation/2026-06-13-nativewindui-to-expo-ui-ideation.md @@ -0,0 +1,165 @@ +--- +date: 2026-06-13 +topic: nativewindui-to-expo-ui-migration +focus: migrate from nativewindui to expo ui +mode: repo-grounded +--- + +# Ideation: nativewindui → Expo UI Migration + +## Grounding Context + +**Codebase shape:** Bun monorepo (3 web + 1 mobile). Mobile app (apps/expo): Expo SDK 56, React Native 0.85, NativeWind v4.2.3. Uses rn-primitives packages (alert-dialog, avatar, checkbox, etc.) and wraps them with `@packrat-ai/nativewindui` v2.2.0. 177 nativewindui imports across 87 files. Team maintains custom TextInput, Button, SearchInput components in `apps/expo/components/`. Recent Expo 56 + React Native 0.85 upgrade (June 2026). + +**Notable patterns:** Feature modules in `apps/expo/features/{name}/` with own components, hooks, screens. Styling via NativeWind (Tailwind for RN) + CSS variable-based color system. State: Jotai (local), React Query (server), Legend State (reactive). Forms: TanStack React Form + Zod. E2E: Maestro with stable testID selectors from `lib/testIds.ts`. Feature flags in `apps/expo/config.ts`. EAS Build with dev/preview/e2e/production profiles + EAS Updates. + +**Pain points:** (1) GitHub Packages token requirement (PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN friction). (2) Type breaking changes (AlertRef → AlertMethods, LargeTitleSearchBarRef → LargeTitleSearchBarMethods) affecting 18+ files per release. (3) Hard to diagnose platform-specific bugs when wrappers hide root causes (Android TextInput keyboard focus issue documented in `docs/solutions/ui-bugs/`). (4) Monolithic nativewindui package (50+ components, but PackRat uses only 15-20). + +**Leverage points:** rn-primitives actively maintained (v1.4.0, March 2026) and covers ~80% of use cases. @expo/ui stable in SDK 56 (same platform PackRat already upgraded to). Web apps (guides, landing) use Shadcn/Radix UI, enabling cross-platform code reuse via rn-primitives foundation. Team already maintains thin wrappers (components/TextInput.tsx shows pattern works). + +**Past learnings:** Institutional experience (Android TextInput bug, documented solution) revealed that wrapper patterns hide root causes. Solution: thin enhancement wrappers (hooks + useImperativeHandle) on top of native APIs. Systematic import migration required. Keep platform-specific wraps minimal. + +**External context:** @expo/ui built on New Architecture (JSI + Fabric). rn-primitives is Radix UI-equivalent for RN (actively maintained, same foundation as web apps). React Native Reusables (copy-own model) gaining traction in ecosystem. NativeBase effectively dead. NativeWind v5 has migration friction (lightningcss issue) — recommend staying on v4 for now, independent of this migration. + +--- + +## Ranked Ideas + +### 1. Monomorphism: Migrate One Component at a Time +**Description:** Pick the highest-impact, lowest-risk component (Text: 27 imports) and migrate all 27 import sites in one PR. Create `apps/expo/components/Text.tsx` wrapping @expo/ui or rn-primitives Text, update all imports, test on device, ship. Repeat for Button (15 imports), then LargeTitleHeader (7), then Refs (Alert, Sheet, ContextMenu). Frontload simple components; ones with ref-forwarding requirements go last. + +**Rationale:** Boring is reliable. No clever abstractions, no generators, no runtime swapping—just straightforward component migration. Each PR becomes an audit trail of what moved and when. Grounded in team practice: `apps/expo/components/TextInput.tsx` (custom wrapper with keyboard fix) and `components/Button.tsx` show the team already maintains thin wrappers and owns their component code. CLAUDE.md emphasizes explicit, auditable code over clever tricks. Allows feature teams to work in parallel (one team handles Text migration, another ships new features simultaneously). Clear success metric: all import sites updated, tests pass, device testing validates behavior. + +**Downsides:** Takes weeks, not days (~1 week per 3-4 components given 87 files). Requires discipline to avoid ad-hoc deviations mid-migration (e.g., "while we're migrating Button, let's also refactor its styles"). Mechanical find-and-replace errors are possible (missing an import site leaves dead code). No single "migration complete" moment; rather, a gradual completion per component. + +**Confidence:** 95% + +**Complexity:** Low + +**Status:** Unexplored + +--- + +### 2. Two-Layer Stable API (Linux HAL Strategy) +**Description:** Define a minimal, version-stable public API in `packages/ui/` that acts as an adapter layer. This layer wraps only the core components PackRat actually uses (Text, Button, LargeTitleHeader, AlertMethods, Sheet, ListItem). Decouple the implementation: `@packrat-ai/nativewindui` remains the underlying implementation, but PackRat depends on a type and export contract that guarantees stability. When nativewindui updates and type names break (AlertRef → AlertMethods), the breaking change is absorbed *inside* the adapter layer, never exposed to consumers. Implementation: remap types via adapter (`export { Alert as Alert, type AlertMethods as AlertMethods }` + type-only adapters). + +**Rationale:** Borrowed from Linux kernel's Hardware Abstraction Layer (HAL) pattern. Kernel internals change; driver code targets the stable HAL, not kernel internals. Institutional grounding: team discovered that platform bugs are easier to fix and diagnose when wrapper layers are thin and their boundaries are explicit. This adapter layer adds ~40-50 lines of code but provides a strong stability contract. Works in parallel with Idea #1 (Monomorphism): migrate Text → adapter guarantees the stable interface → downstream callers update imports once per component, not many times per nativewindui version bump. Reduces the blast radius of upstream breaking changes. + +**Downsides:** Adds a layer, which is "one more indirection to understand." Requires discipline to keep the adapter surface minimal and avoid re-exporting unnecessary types or utilities. Requires updating the adapter each time nativewindui updates, but this is a single-file change (packages/ui/nativewindui/index.ts). Type remapping can be fragile if nativewindui's internal types are complex. + +**Confidence:** 90% + +**Complexity:** Low + +**Status:** Unexplored + +--- + +### 3. Dual-Mode Wrapper (Fast + Slow Path) +**Description:** Keep nativewindui as the legacy "slow path" in `packages/ui/nativewindui` (unchanged). Introduce a new "fast path" with thin direct re-exports in `apps/expo/components/{Text,Button,LargeTitleHeader}.tsx` that wrap @expo/ui or rn-primitives directly. Both paths export the same interface. Screens and features can opt-in to the fast path without coordinating a massive migration. Feature teams migrate their imports as they touch a component during normal refactoring; no forced cutover date or migration sprint required. + +**Rationale:** Automotive sidecar pattern: applications ran in lightweight containers, but high-velocity features (animations, gestures) used native code directly when container overhead was noticeable. Works because the interface is identical, so callers don't care which implementation they're using. Grounded in reality: `apps/expo/components/TextInput.tsx` and `apps/expo/components/Button.tsx` already exist as thin custom wrappers; the team *likes* owning their component code, maintaining thin wrappers, and having tight feedback loops. Dual-mode + Monomorphism means feature teams drive the migration organically (when the packing-list team refactors, they migrate to fast path; when a feature stays stable, legacy path is fine). Massively reduces coordination burden and shipping delays. + +**Downsides:** Maintains two code paths in parallel (increases test matrix slightly—need to test both paths for each component). Requires clear signposting and team convention ("always use fast path for new code, legacy path is for compatibility only"). Teams can diverge if not disciplined (some routes use old, some use new). Slightly higher cognitive load during transition period. + +**Confidence:** 93% + +**Complexity:** Low-Medium + +**Status:** Unexplored + +--- + +### 4. Feature-Flag Component Versions (Staged Rollout) +**Description:** Add feature-flag entries to `apps/expo/config.ts` (e.g., `COMPONENT_TEXT_USE_EXPO_UI: false`, `COMPONENT_BUTTON_USE_EXPO_UI: false`). New components ship with the flag off by default. Conditional rendering in code: if flag is true, use @expo/ui component; else, use nativewindui fallback. Release 1 (preview): flag still off (legacy path active). Release 2 (preview): enable the flag in preview builds only, collect telemetry (Sentry errors, performance, UX metrics) from preview users. Release 3 (prod): if all signals green, ship to 10% of production users (if EAS Segments supports it), then roll to 100%. Revert to nativewindui implementation zero-cost if a regression emerges (just flip the flag off). + +**Rationale:** Feature flags are the team's established pattern (already in `apps/expo/config.ts`, EAS profiles are configured). Turns each component migration into a low-risk experiment with instrumented rollback. Grounded in capability: team has EAS Updates, so new component logic can ship without triggering an app store rebuild. Decouples component adoption from SDK releases. Enables continuous design iteration without the traditional "we must wait for iOS App Store review and Android Play Store review" cadence. Supports A/B testing if needed (show @expo/ui Button to 50% of users, nativewindui to the other 50%, compare adoption/satisfaction). + +**Downsides:** Adds conditional logic to every component (slight code complexity, requires careful testing of both branches). Requires thinking about what "flag off" means (usually: fall back to nativewindui implementation or keep current behavior). Requires Sentry + analytics to be configured so regressions surface (crashing, poor performance). If a component has many props, conditional rendering can become verbose. Risk of bit rot (a flag left off indefinitely, code path becomes stale). + +**Confidence:** 92% + +**Complexity:** Low-Medium + +**Status:** Unexplored + +--- + +### 5. Vertical Code Reuse (Mobile-Web Convergence) +**Description:** Recognize that rn-primitives (used on mobile via nativewindui) and Radix UI (used on web via Shadcn in `apps/guides` and `apps/landing`) share a compositional foundation. After migrating mobile to rn-primitives-based components, build a bridge package (`@packrat/rn-primitives-web` or similar) that wraps Shadcn components with rn-primitives interfaces. This allows form components, buttons, checkboxes, inputs, etc. to run on all three platforms (iOS/Android/web) from the *same* TypeScript source. Example: a "multiselect items" component built on `@rn-primitives/checkbox` becomes reusable on mobile and web without duplication. + +**Rationale:** Strategic multiplier. Today, TextInput on mobile (RN TextInput) and web (Shadcn input) are completely separate codebases. Same for Button, Checkbox, Switch, Select. Unified primitives-based API means form logic, validation, error states, and event handling code compile to both platforms from one source file. Each new form feature (AI-assisted packing suggestions, trip creation flow, outfit builder) lands everywhere automatically. Grounded in market signal: rn-primitives (v1.4.0, March 2026) is actively maintained, has 10+ primitives (alert-dialog, avatar, checkbox, context-menu, dropdown-menu, etc.) that map cleanly to Radix UI's component set. High leverage: form-heavy features become 3x faster to ship. + +**Downsides:** Requires design discipline (form components must stay on the rn-primitives interface boundary, not diverge to platform-specific styling tricks). Initial design work is significant (~2-4 weeks to design @packrat/rn-primitives-web adapter + refactor one form as proof-of-concept). Long-term payoff is high but back-loaded (value compounds over quarters as more forms use the bridge). Requires web and mobile teams to coordinate on form contracts. + +**Confidence:** 85% + +**Complexity:** Medium-High + +**Status:** Unexplored + +--- + +### 6. Visual Snapshots + Auto-Regression Detection +**Description:** Snapshot-test the app's visual rendering of each component under both old (nativewindui) and new (@expo/ui) implementations. Use Percy or Detox visual testing tools. For each component migration: old-implementation → screenshot → new-implementation → screenshot → pixel-diff. If they match (pixel-perfect or perceptual distance < 5%), the migration is safe. Before shipping a migrated component to production, require: (1) unit tests pass, (2) E2E tests pass, (3) visual snapshots match old behavior, (4) Maestro flows pass on both iOS and Android. + +**Rationale:** Game engines solved "will my game work on UE5 vs. Godot?" by comparing visual output objectively. PackRat learned that AlertRef → AlertMethods broke 18+ files, and type-only fixes might mask rendering regressions. Visual snapshots catch subtle layout shifts, color mismatches, and spacing changes that code review might miss. Grounded in capability: `apps/expo` already runs Maestro E2E tests. Visual snapshot infra (Percy, Detox visual checks) integrates with existing test harness (CI already configured for Maestro). Doesn't require human code review of all 87 files; just "render and diff." + +**Downsides:** Requires tooling setup (Percy or Detox integration, ~2-4 hours of CI configuration). Snapshots can be flaky if rendering is non-deterministic (animations, network data loading, dynamic text). Requires a baseline (initial snapshot) that might itself have bugs, so snapshots validate "change is consistent" not "change is correct." Snapshot reviews are manual (diff reviews can be tedious). + +**Confidence:** 88% + +**Complexity:** Medium + +**Status:** Unexplored + +--- + +### 7. Automate Import Migration with Codemod +**Description:** Write a jscodeshift codemod that rewrites all `@packrat/ui/nativewindui` imports to their replacement paths (@expo/ui, rn-primitives, or local components). The codemod patterns matches ~90 import sites automatically (Text: 27 uses → import from new path, Button: 15 uses → new path, etc.). For custom components (LargeTitleSearchBar, FormSection), the codemod leaves them untouched or stubs them with a deprecation comment. Run locally: `jscodeshift -t migration-codemod.js apps/expo/`. Validate output, commit changes. Mechanical work (177 imports across 87 files) becomes a single command + testing. + +**Rationale:** Automates the most tedious part of Monomorphism. Team already has precedent: `packages/api/scripts/lint/...` scripts validate generated code in CI. Codemods are standard in TypeScript ecosystem (major framework migrations, e.g., React, use jscodeshift). Reduces human errors (typos, missed imports, inconsistent refactoring). Makes bulk migration mechanical and repeatable—if the codemod is correct once, it's correct every time. Grounded in codebase: 177 imports are high but consistent (mostly Text, Button, Sheet, LargeTitleHeader, ListItem); pattern-matching is straightforward. + +**Downsides:** Requires writing + maintaining the codemod (1-2 days upfront). If component APIs differ significantly (e.g., nativewindui's custom props don't exist in @expo/ui), the codemod can't auto-fix everything; those require manual migration or an interim wrapper. Error recovery is manual (if the codemod produces bad code, you must fix it or revert and refine the codemod). Codemod must be validated on a test branch before running on main. + +**Confidence:** 87% + +**Complexity:** Low-Medium + +**Status:** Unexplored + +--- + +## Rejection Summary + +**27 ideas rejected.** Common rejection reasons: + +- **Duplicates / Subsumed:** Removing wrapper layer entirely (high value but extreme burden; #2 provides 80% benefit with 20% cost). Unblock type-checking (subsumed by #2 adapter layer). Code generation (subsumed by #2). Copy-own UI library (alternative path, but requires ongoing maintenance; primary path is lower effort). +- **Over-Engineering:** Styling primitives + behavioral wrappers (redundant with #2). Component library as sub-libraries (monorepo is simpler). Compiler transform / TypeScript plugin (too expensive for bulk migration; codemod #7 is lighter). +- **Not Aligned with Strategy:** Platform-specific divergence (contradicts PackRat's unified design). Remove CSS (solves non-existent problem; NativeWind is working). Runtime component swapping (powerful but more complex than feature flags). +- **Too Minor:** Eliminate GitHub token (onboarding friction, not blocking). Remove web duplications (1 file, nice-to-have). +- **Not Actionable:** Two-week blitz (stress-driven delivery, unrealistic timeline). Lock in convergence window (artificial deadline). Island components (valuable long-term, but premature before understanding migration impact). + +--- + +## Implementation Sequence (Recommended) + +1. **Start:** #2 (Two-Layer Stable API) — set up adapter layer in packages/ui/ as infrastructure for stability. +2. **Parallel:** #1 (Monomorphism) — start with Text component (27 imports), single PR, test thoroughly. +3. **Parallel:** #7 (Automate) — write codemod for next components (Button, LargeTitleHeader). +4. **Validate:** #6 (Visual Snapshots) — set up before shipping migrated components; each component verified. +5. **Rollout:** #4 (Feature Flags) — gate new components, preview testing before prod. +6. **Integrate:** #3 (Dual-Mode) — teams migrate incrementally as they touch features; no forced cutover. +7. **Extend:** #5 (Mobile-Web Reuse) — once core components (Button, Text, Input, Sheet) are stable, build rn-primitives-web adapter for form reuse. + +--- + +## Quality Bar + +- ✅ Grounded in codebase context (Expo SDK 56, RN 0.85, 177 imports across 87 files) +- ✅ Candidates generated before filtering (47 ideas across 6 ideation frames) +- ✅ Many-ideas → critique → survivors mechanism preserved +- ✅ Every rejected idea has a documented reason +- ✅ Survivors form coherent strategy (not just individual improvements) +- ✅ Pragmatism prioritized (boring beats clever; existing infrastructure reused) +- ✅ Leverage identified (cross-platform code reuse, ecosystem contribution) diff --git a/docs/migrations/nativewindui-to-expo-ui.md b/docs/migrations/nativewindui-to-expo-ui.md new file mode 100644 index 0000000000..75665a8189 --- /dev/null +++ b/docs/migrations/nativewindui-to-expo-ui.md @@ -0,0 +1,162 @@ +--- +started: 2026-06-14 +status: in-progress +tracking: packages/ui/nativewindui/index.ts +progress-cmd: bun check:migration +--- + +# NativeWindUI → Expo UI Migration + +## Why + +NativeWindUI was chosen for native look and feel. Expo UI now provides that directly — via SwiftUI on iOS and Jetpack Compose on Android — without requiring a private GitHub Packages token, without type-breaking changes on every upstream release, and without wrapper opacity hiding platform bugs. + +## Rules + +1. **`@expo/ui` is the primary source.** Every component gets its replacement from `@expo/ui` first. +2. **Universal before platform-specific.** `@expo/ui` Universal components run on iOS, Android, and web from one file. Use them when available. Use SwiftUI/JC platform-specific variants only when Universal doesn't cover the use case. +3. **RN core is last resort.** Only fall back to `react-native` when `@expo/ui` has no equivalent (e.g. `useColorScheme`). +4. **All UI lives in `packages/ui`.** No UI components in `apps/expo/components/` — those are either being replaced or moved. +5. **`apps/expo/components/` cleanup is a parallel track** — see section below. + +## How progress is tracked + +`packages/ui/nativewindui/index.ts` is the live tracker — one export line per component still backed by `@packrat-ai/nativewindui`. Delete a line when its `packages/ui` replacement lands. When the file is empty, remove `@packrat-ai/nativewindui` from `packages/ui/package.json` and drop `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN` from `bunfig.toml`. + +```bash +bun check:migration # prints per-phase count + fails if any file bypasses the adapter +``` + +## Replacement map + +Priority column: **U** = `@expo/ui` Universal, **S** = `@expo/ui` SwiftUI (iOS), **JC** = `@expo/ui` Jetpack Compose (Android), **C** = `@expo/ui` community drop-in, **RN** = `react-native` (last resort only), **ER** = `expo-router`. + +| NativeWindUI | Uses | @expo/ui replacement | Priority | packages/ui file | +|---|---|---|---|---| +| `Text` | 114 | `Text` | U | `src/text.tsx` | +| `Button` | 49 | `Button` | U | `src/button.tsx` | +| `ActivityIndicator` | 22 | `ProgressView` / `LoadingIndicator` | S + JC | `src/loading-indicator.ios.tsx` + `.android.tsx` | +| `ListItem` | 21 | `ListItem` (+ `.Leading` `.Trailing` `.Supporting`) | U | `src/list-item.tsx` | +| `LargeTitleHeader` + `LargeTitleSearchBarMethods` | 25 | `Stack.Screen.Title` + `Stack.SearchBar` + `Stack.Toolbar` | ER | — (navigation layer, not packages/ui) | +| `Alert` + `AlertMethods` + `AlertAnchor` | 25 | `Alert` / `AlertDialog` + `BasicAlertDialog` | S + JC | `src/alert.ios.tsx` + `.android.tsx` | +| `Sheet` + `useSheetRef` | 16 | `BottomSheet` | U | `src/bottom-sheet.tsx` | +| `Form` | 8 | `Form` / `FieldGroup` | S + U | `src/form.ios.tsx` + `.tsx` | +| `FormSection` | 8 | `Section` / `FieldGroup.Section` | S + U | `src/form-section.ios.tsx` + `.tsx` | +| `FormItem` | 8 | `LabeledContent` / `FieldGroup.Section` row | S + U | part of form-section | +| `TextField` | 9 | `TextInput` | U | `src/text-input.tsx` | +| `Card` + `CardContent` + `CardTitle` | 8 | `Card` / custom `View` | JC + custom iOS | `src/card.android.tsx` + `.ios.tsx` | +| `SegmentedControl` | 3 | `SegmentedControl` | C | `src/segmented-control.tsx` | +| `Toggle` | 1 | `Switch` | U | `src/switch.tsx` | +| `List` | 1 | `List` | U | `src/list.tsx` | +| `ContextMenuMethods` | 1 | `ContextMenu` / `DropdownMenu` | S + JC | `src/context-menu.ios.tsx` + `.android.tsx` | +| `SearchInput` / `AdaptiveSearchHeader` | 1 | `Stack.SearchBar` | ER | — (navigation layer) | +| `Avatar` + `AvatarFallback` + `AvatarImage` | 6 | `@rn-primitives/avatar` (no @expo/ui Avatar) | — | `src/avatar.tsx` | +| `useColorScheme` | 20 | `useColorScheme` from `react-native` (no @expo/ui hook) | RN | — (hook, not a component) | +| `cn` | 3 | remove — import `tailwind-merge` directly | — | — (utility, not a component) | + +## `apps/expo/components/` cleanup (parallel track) + +Everything here is either replaced by `@expo/ui` via `packages/ui` or moved to a feature folder. Nothing new should be added here. + +| File | Action | +|---|---| +| `Button.tsx` | Delete — replaced by `packages/ui` `Button` | +| `Card.tsx` | Delete — replaced by `packages/ui` `Card` | +| `TextInput.tsx` | Delete — replaced by `packages/ui` `TextInput` (port the Android keyboard fix into it) | +| `SearchInput.tsx` | Delete — replaced by `Stack.SearchBar` / `headerSearchBarOptions` | +| `ThemeToggle.tsx` | Move to `packages/ui/src/theme-toggle.tsx` | +| `Container.tsx` | Move to `packages/ui/src/container.tsx` | +| `ErrorState.tsx` | Move to `packages/ui/src/error-state.tsx` | +| `ScreenContent.tsx` | Move to `packages/ui/src/screen-content.tsx` | +| `Markdown.tsx` | Move to `packages/ui/src/markdown.tsx` | +| `Icon/` | Move to `packages/ui/src/icon/` — wraps `@expo/ui` Universal `Icon` | +| `LargeTitleHeaderOverlapFixIOS.tsx` | Move to `packages/ui/src/large-title-header-overlap-fix-ios.tsx` — still needed | +| `LargeTitleHeaderSearchContentContainer.tsx` | Absorbed into platform SearchOverlay components — delete | +| `AndroidTabBarInsetFix.tsx` | Move to `packages/ui/src/android-tab-bar-inset-fix.android.tsx` | +| `BackButton.tsx` | Move to `packages/ui/src/back-button.tsx` | +| `HeaderButton.tsx` | Move to `packages/ui/src/header-button.tsx` | +| `TabBarIcon.tsx` | Move to `packages/ui/src/tab-bar-icon.tsx` | +| `CategoriesFilter.tsx` | Move to `apps/expo/features/catalog/components/` | +| `ai-chatHeader.tsx` | Move to `apps/expo/features/ai-chat/components/` | +| `EditScreenInfo.tsx` | Delete (dev-only artefact) | +| `initial/` | Audit each file — move to relevant feature folder or `packages/ui` | + +## Phases + +### Phase 1 — Non-UI cleanup (no device testing needed) +Remove utilities from the adapter that were never UI components. + +- `useColorScheme` → change 20 import sites to `react-native` +- `cn` → inline the 3 call sites with `tailwind-merge` or delete + +Estimated effort: half a day. Ship as one PR. + +### Phase 2 — Expo Router native patterns (42 uses) +Replace ref-based imperative navigation APIs. + +- `LargeTitleHeader` → restructure each screen to use `Stack.Screen.Title`, `Stack.SearchBar`, `Stack.Toolbar`. Each screen group (home, packs, catalog, trips, profile) is one sub-PR. +- `Sheet` + `useSheetRef` → replace `ref.current.present()` calls with `router.push('/sheet-route')` + `presentation: 'formSheet'` in the Stack layout. New route files replace old modal components. +- `SearchInput` / `AdaptiveSearchHeader` → `Stack.SearchBar` or `headerSearchBarOptions`. +- `LargeTitleHeaderOverlapFixIOS` and `LargeTitleHeaderSearchContentContainer` are moved to `packages/ui/src/` and remain available for screens that need them. + +Estimated effort: 3–5 days. One PR per tab section. + +### Phase 3 — Universal @expo/ui components in packages/ui (high frequency) +Wire up `packages/ui/src/` files that re-export or wrap Universal components. Import sites change from `@packrat/ui/nativewindui` to `@packrat/ui`. + +Order by frequency: + +1. **`Text` (114 uses)** — `packages/ui/src/text.tsx` wrapping `@expo/ui` Universal `Text`. Preserve the `className` prop via a thin NativeWind shim so call sites only change the import path, not the JSX. +2. **`Button` (49 uses)** — `packages/ui/src/button.tsx` wrapping `@expo/ui` Universal `Button`. Map nativewindui variant/size props to `@expo/ui` equivalents. +3. **`ListItem` (21 uses)** — `packages/ui/src/list-item.tsx` re-exporting `@expo/ui` Universal `ListItem` with `Leading`, `Trailing`, `Supporting` sub-components. +4. **`Sheet` + `useSheetRef` (16 uses)** — `packages/ui/src/bottom-sheet.tsx` wrapping `@expo/ui` Universal `BottomSheet`. Replace `useSheetRef` with `isPresented` / `onDismiss` props. +5. **`Form` + `FormSection` + `FormItem` (24 uses)** — `packages/ui/src/form.tsx` wrapping `FieldGroup` + `FieldGroup.Section` (Universal); `packages/ui/src/form.ios.tsx` wrapping SwiftUI `Form` + `Section` for native iOS grouped lists. +6. **`TextField` (9 uses)** — `packages/ui/src/text-input.tsx` wrapping `@expo/ui` Universal `TextInput`. Port the Android keyboard focus fix from the existing `apps/expo/components/TextInput.tsx`. +7. **`Toggle` (1 use)** — `packages/ui/src/switch.tsx` re-exporting `@expo/ui` Universal `Switch`. +8. **`List` (1 use)** — `packages/ui/src/list.tsx` re-exporting `@expo/ui` Universal `List`. + +Estimated effort: 1 week across PRs. + +### Phase 4 — Platform-specific @expo/ui wrappers in packages/ui +Components that need per-platform files because @expo/ui has different APIs on iOS vs Android. + +- **`ActivityIndicator` (22 uses)** — `packages/ui/src/loading-indicator.ios.tsx` (SwiftUI `ProgressView`) + `packages/ui/src/loading-indicator.android.tsx` (JC `LoadingIndicator`). Metro picks up the platform file automatically. +- **`Alert` + `AlertMethods` + `AlertAnchor` (25 uses)** — `packages/ui/src/alert.ios.tsx` (SwiftUI `Alert` with `Alert.Trigger`, `Alert.Actions`, `Alert.Message`) + `packages/ui/src/alert.android.tsx` (JC `AlertDialog` / `BasicAlertDialog`). +- **`Card` family (8 uses)** — `packages/ui/src/card.android.tsx` (JC `Card`) + `packages/ui/src/card.ios.tsx` (custom `View`-based card, SwiftUI has no `Card` primitive). +- **`ContextMenuMethods` (1 use)** — `packages/ui/src/context-menu.ios.tsx` (SwiftUI `ContextMenu` with `Trigger`, `Items`, `Preview`) + `packages/ui/src/context-menu.android.tsx` (JC `DropdownMenu`). +- **`SegmentedControl` (3 uses)** — `packages/ui/src/segmented-control.tsx` re-exporting from `@expo/ui/community/segmented-control`. + +Estimated effort: 3–5 days. + +### Phase 5 — No @expo/ui equivalent +- **`Avatar` family (6 uses)** — `packages/ui/src/avatar.tsx` wrapping `@rn-primitives/avatar` (same foundation nativewindui used internally; only import path changes). + +Estimated effort: half a day. + +### Phase 6 — packages/ui restructure + apps/expo/components/ removal +- Add `packages/ui/src/` directory with proper exports and `tsconfig.json` path alias. +- Move / delete all files from `apps/expo/components/` per the cleanup table above. +- Update root `tsconfig.json` and `packages/ui/package.json` to export from `src/` instead of wrapping `@packrat-ai/nativewindui`. +- Enable `check-types` in `packages/ui/package.json` (currently disabled because tsc deep-checked nativewindui source `.tsx` files and surfaced 197 upstream errors — that problem goes away once we own the source). + +Estimated effort: 1–2 days. + +### Phase 7 — Final removal +Once the adapter file is empty and all `apps/expo/components/` files are gone: + +- Remove `@packrat-ai/nativewindui` from `packages/ui/package.json`. +- Remove the `@packrat-ai` scope from `bunfig.toml`. +- Remove `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN` from `.env.local` docs, CI secrets, and the `CLAUDE.md` private package auth section. +- Delete `packages/ui/nativewindui/` directory. +- Delete `scripts/lint/nativewindui-migration.ts` and remove `check:migration` from `package.json`. + +## PR checklist per component + +- [ ] Replacement lives in `packages/ui/src/` +- [ ] Platform-specific files (`.ios.tsx` / `.android.tsx`) used where needed +- [ ] Renders correctly on iOS + Android (dark mode included) +- [ ] Existing Maestro E2E flows pass +- [ ] `bun check:migration` exits 0 +- [ ] `bun check-types` exits 0 +- [ ] Corresponding line(s) removed from `packages/ui/nativewindui/index.ts` +- [ ] No remaining imports from `apps/expo/components/` for migrated component diff --git a/package.json b/package.json index 9286f99e4e..1c8d5056b3 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "check:coverage:update": "bun run scripts/lint/coverage-baseline-update.ts", "check:deps": "manypkg check", "check:magic-strings": "bun run --cwd packages/checks check:magic-strings", + "check:migration": "bun scripts/lint/nativewindui-migration.ts", "check:package-json": "bun scripts/format/sort-package-json.ts --check", "check:react-doctor": "bun scripts/lint/check-react-doctor.ts", "check-types": "bun scripts/check-types.ts", diff --git a/packages/ui/nativewindui/index.ts b/packages/ui/nativewindui/index.ts index 19556f3fcc..fae00c3b2c 100644 --- a/packages/ui/nativewindui/index.ts +++ b/packages/ui/nativewindui/index.ts @@ -1 +1,64 @@ -export * from '@packrat-ai/nativewindui'; +// NativeWindUI → Expo UI migration tracker +// +// Each export line is one component still backed by @packrat-ai/nativewindui. +// Delete a line when its packages/ui/src/ replacement lands. +// When this file is empty: remove @packrat-ai/nativewindui from package.json +// and drop PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN from bunfig.toml. +// +// Run `bun check:migration` for per-phase progress. +// Full plan: docs/migrations/nativewindui-to-expo-ui.md +// +// Phase 1 ✓ done — useColorScheme → expo-app/lib/hooks/useColorScheme, cn → expo-app/lib/cn +// Phase 2 — LargeTitleHeader/SearchInput → Stack.Screen + headerSearchBarOptions +// LargeTitleHeader ✓ done +// SearchInput — reverted, pending re-migration +export { SearchInput } from '@packrat-ai/nativewindui'; // uses → headerSearchBarOptions +export type { SearchInputProps, SearchInputRef } from '@packrat-ai/nativewindui'; +// +// Phase 3 — @expo/ui Universal → packages/ui/src/ +export { Text, TextClassContext, textVariants } from '@packrat-ai/nativewindui'; // 114 uses → @expo/ui Universal Text +export { Button, buttonVariants, buttonTextVariants } from '@packrat-ai/nativewindui'; // 49 uses → @expo/ui Universal Button +export type { ButtonProps } from '@packrat-ai/nativewindui'; +export { + ListItem, + List, + ListSectionHeader, + getStickyHeaderIndices, +} from '@packrat-ai/nativewindui'; // 22 uses → @expo/ui Universal ListItem + List +export type { + ListDataItem, + ListItemProps, + ListProps, + ListRef, + ListRenderItemInfo, + ListSectionHeaderProps, +} from '@packrat-ai/nativewindui'; +export { Sheet, useSheetRef } from '@packrat-ai/nativewindui'; // 16 uses → @expo/ui Universal BottomSheet +export { Form, FormSection, FormItem } from '@packrat-ai/nativewindui'; // 24 uses → @expo/ui Universal FieldGroup + SwiftUI Form +export { TextField } from '@packrat-ai/nativewindui'; // 9 uses → @expo/ui Universal TextInput +export type { TextFieldProps, TextFieldRef } from '@packrat-ai/nativewindui'; +export { Toggle } from '@packrat-ai/nativewindui'; // 1 use → @expo/ui Universal Switch +// +// Phase 4 — @expo/ui platform-specific wrappers (.ios.tsx + .android.tsx) in packages/ui/src/ +export { ActivityIndicator } from '@packrat-ai/nativewindui'; // 22 uses → ProgressView (iOS) + LoadingIndicator (Android) +export { Alert, AlertAnchor } from '@packrat-ai/nativewindui'; // 14 uses → @expo/ui SwiftUI Alert + JC AlertDialog +export type { AlertMethods } from '@packrat-ai/nativewindui'; // 14 uses +export { + Card, + CardContent, + CardTitle, + CardBadge, + CardDescription, + CardFooter, + CardImage, + CardSubtitle, +} from '@packrat-ai/nativewindui'; // 8 uses → JC Card (Android) + custom View (iOS) +export { SegmentedControl } from '@packrat-ai/nativewindui'; // 3 uses → @expo/ui community SegmentedControl +export { Checkbox } from '@packrat-ai/nativewindui'; // 3 uses → @expo/ui Universal Checkbox +export { ContextMenu, createContextItem, createContextSubMenu } from '@packrat-ai/nativewindui'; // multiple uses → SwiftUI ContextMenu + JC DropdownMenu +export type { ContextMenuMethods } from '@packrat-ai/nativewindui'; +export { DropdownMenu, createDropdownItem, createDropdownSubMenu } from '@packrat-ai/nativewindui'; // multiple uses → @expo/ui DropdownMenu +export { Toolbar, ToolbarCTA, ToolbarIcon } from '@packrat-ai/nativewindui'; // multiple uses → platform-specific Toolbar +// +// Phase 5 — no @expo/ui equivalent +export { Avatar, AvatarFallback, AvatarImage } from '@packrat-ai/nativewindui'; // 6 uses → @rn-primitives/avatar diff --git a/packages/ui/src/app-bar/AppBarAndroid.tsx b/packages/ui/src/app-bar/AppBarAndroid.tsx new file mode 100644 index 0000000000..59b21ed40f --- /dev/null +++ b/packages/ui/src/app-bar/AppBarAndroid.tsx @@ -0,0 +1,67 @@ +import { Icon } from 'expo-app/components/Icon'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const ACTION_ROW_HEIGHT = 64; +const TITLE_ROW_HEIGHT = 72; + +type AppBarAndroidProps = { + back?: { title?: string }; + options: { + title?: string; + headerRight?: (props: { tintColor?: string; canGoBack?: boolean }) => React.ReactNode; + }; + navigation: { goBack: () => void }; +}; + +export function AppBarAndroid({ back, options, navigation }: AppBarAndroidProps) { + const insets = useSafeAreaInsets(); + const { colors } = useColorScheme(); + const canGoBack = !!back; + + return ( + + + {canGoBack && ( + navigation.goBack()} hitSlop={8} style={styles.navButton}> + + + )} + + {options.headerRight?.({ tintColor: colors.foreground, canGoBack })} + + + + {options.title} + + + + ); +} + +const styles = StyleSheet.create({ + container: { width: '100%' }, + actionRow: { + height: ACTION_ROW_HEIGHT, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 4, + }, + navButton: { padding: 14 }, + flex: { flex: 1 }, + titleRow: { + height: TITLE_ROW_HEIGHT, + justifyContent: 'flex-end', + paddingHorizontal: 20, + paddingBottom: 16, + }, + title: { fontSize: 32, fontWeight: '400', letterSpacing: 0 }, +}); diff --git a/packages/ui/src/app-bar/index.tsx b/packages/ui/src/app-bar/index.tsx new file mode 100644 index 0000000000..3c9da697e3 --- /dev/null +++ b/packages/ui/src/app-bar/index.tsx @@ -0,0 +1,26 @@ +import { Platform } from 'react-native'; +import { AppBarAndroid } from './AppBarAndroid'; + +type AppBarAndroidProps = React.ComponentProps; + +/** + * Returns Stack.Screen options that render a platform-native large-title header: + * - iOS: native headerLargeTitle (collapses on scroll automatically) + * - Android: fixed MD3 Large Top App Bar (no collapse) + * + * Usage: + * + */ +export function getAppBarOptions() { + if (Platform.OS !== 'android') { + return { + headerLargeTitle: true, + headerBackButtonDisplayMode: 'minimal' as const, + }; + } + + return { + header: (props: AppBarAndroidProps) => , + headerShown: true, + }; +} diff --git a/apps/expo/components/LargeTitleHeaderOverlapFixIOS.tsx b/packages/ui/src/large-title-header-overlap-fix-ios.tsx similarity index 59% rename from apps/expo/components/LargeTitleHeaderOverlapFixIOS.tsx rename to packages/ui/src/large-title-header-overlap-fix-ios.tsx index ae6b71a30e..a18587d9d2 100644 --- a/apps/expo/components/LargeTitleHeaderOverlapFixIOS.tsx +++ b/packages/ui/src/large-title-header-overlap-fix-ios.tsx @@ -1,18 +1,15 @@ +import type { ReactNode } from 'react'; import { Platform, SafeAreaView, View } from 'react-native'; -export const LargeTitleHeaderOverlapFixIOS = ({ children }: { children?: React.ReactNode }) => { +export function LargeTitleHeaderOverlapFixIOS({ children }: { children?: ReactNode }) { if (Platform.OS === 'android') { - if (!children) { - return null; - } else { - return children; - } + if (!children) return null; + return <>{children}; } - return ( {children} {!children && } ); -}; +} diff --git a/packages/ui/src/search-overlay/SearchOverlay.ios.tsx b/packages/ui/src/search-overlay/SearchOverlay.ios.tsx new file mode 100644 index 0000000000..6067b4820c --- /dev/null +++ b/packages/ui/src/search-overlay/SearchOverlay.ios.tsx @@ -0,0 +1,35 @@ +import { Stack } from 'expo-router'; +import { useState } from 'react'; +import { SafeAreaView, StyleSheet } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import type { SearchOverlayProps } from './types'; + +export function SearchOverlay({ placeholder, value, onChangeText, children }: SearchOverlayProps) { + const [isFocused, setIsFocused] = useState(false); + + return ( + <> + onChangeText(e.nativeEvent.text), + onFocus: () => setIsFocused(true), + onBlur: () => setIsFocused(false), + }, + }} + /> + {(isFocused || value.length > 0) && ( + + {children} + + )} + + ); +} diff --git a/packages/ui/src/search-overlay/SearchOverlay.tsx b/packages/ui/src/search-overlay/SearchOverlay.tsx new file mode 100644 index 0000000000..dbb9ca4958 --- /dev/null +++ b/packages/ui/src/search-overlay/SearchOverlay.tsx @@ -0,0 +1,137 @@ +import { Portal } from '@rn-primitives/portal'; +import { Icon } from 'expo-app/components/Icon'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { Stack } from 'expo-router'; +import { useCallback, useEffect, useId, useState } from 'react'; +import { BackHandler, Pressable, StyleSheet, TextInput, View } from 'react-native'; +import Animated, { + FadeIn, + FadeInRight, + FadeInUp, + FadeOut, + FadeOutRight, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import type { SearchOverlayProps } from './types'; + +export function SearchOverlay({ + placeholder, + value, + onChangeText, + children, + androidHeaderRightActions, +}: SearchOverlayProps) { + const [isOpen, setIsOpen] = useState(false); + const { colors, isDarkColorScheme } = useColorScheme(); + const insets = useSafeAreaInsets(); + const id = useId(); + + const headerBg = isDarkColorScheme ? colors.card : colors.background; + const contentBg = isDarkColorScheme ? colors.background : colors.card; + const iconColor = isDarkColorScheme ? colors.grey2 : colors.grey3; + + const close = useCallback(() => { + setIsOpen(false); + onChangeText(''); + }, [onChangeText]); + + useEffect(() => { + const handler = BackHandler.addEventListener('hardwareBackPress', () => { + if (isOpen) { + close(); + return true; + } + return false; + }); + return () => handler.remove(); + }, [isOpen, close]); + + return ( + <> + ( + + {androidHeaderRightActions} + setIsOpen(true)} style={styles.searchButton}> + + + + ), + }} + /> + {isOpen && ( + + + + + + + + + + + { + if (value.length === 0) close(); + }} + autoCapitalize="none" + returnKeyType="search" + style={[styles.input, { color: colors.foreground }]} + placeholderTextColor={colors.grey2} + /> + + {!!value && ( + + onChangeText('')} + hitSlop={8} + style={styles.clearButton} + > + + + + )} + + + + {children} + + + + )} + + ); +} + +const styles = StyleSheet.create({ + portal: { backgroundColor: 'transparent' }, + header: {}, + headerRightRow: { flexDirection: 'row', alignItems: 'center', gap: 4 }, + searchButton: { padding: 14 }, + inputRow: { + height: 64, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + }, + backButton: { padding: 12 }, + inputFlex: { flex: 1 }, + input: { flex: 1, fontSize: 20, paddingHorizontal: 8 }, + clearButton: { padding: 8 }, + content: { flex: 1 }, +}); diff --git a/packages/ui/src/search-overlay/index.ts b/packages/ui/src/search-overlay/index.ts new file mode 100644 index 0000000000..427eb47145 --- /dev/null +++ b/packages/ui/src/search-overlay/index.ts @@ -0,0 +1,2 @@ +export { SearchOverlay } from './SearchOverlay'; +export type { SearchOverlayProps } from './types'; diff --git a/packages/ui/src/search-overlay/types.ts b/packages/ui/src/search-overlay/types.ts new file mode 100644 index 0000000000..936f68215f --- /dev/null +++ b/packages/ui/src/search-overlay/types.ts @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; + +export interface SearchOverlayProps { + placeholder?: string; + value: string; + onChangeText: (text: string) => void; + children: ReactNode; + /** + * Android only — extra header-right content rendered before the search icon. + * On iOS the parent Stack.Screen headerRight handles this; this prop is ignored. + */ + androidHeaderRightActions?: ReactNode; +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index b2bddfc142..e79675ac35 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@packrat/typescript-config/react-native.json", - "include": ["nativewindui/**/*.ts", "nativewindui/**/*.tsx"] + "include": ["nativewindui/**/*.ts", "nativewindui/**/*.tsx", "src/**/*.ts", "src/**/*.tsx"] } diff --git a/scripts/lint/nativewindui-migration.ts b/scripts/lint/nativewindui-migration.ts new file mode 100644 index 0000000000..4b995c0862 --- /dev/null +++ b/scripts/lint/nativewindui-migration.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env bun +// +// nativewindui-migration.ts — tracks NativeWindUI → Expo UI migration progress. +// +// Reads packages/ui/nativewindui/index.ts and counts remaining export lines per +// phase. Scans apps/expo for any direct imports from @packrat-ai/nativewindui +// (which bypass the adapter and indicate a stale callsite). +// +// Exit codes: +// 0 — clean (no violations; progress printed to stdout) +// 1 — violations found (direct imports bypassing adapter) + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dir, '..', '..'); +const ADAPTER = join(ROOT, 'packages/ui/nativewindui/index.ts'); +const EXPO_SRC = join(ROOT, 'apps/expo'); + +// ── 1. Count remaining exports per phase ──────────────────────────────────── + +const adapterLines = readFileSync(ADAPTER, 'utf8').split('\n'); + +type Phase = '1' | '2' | '3' | '4' | '5'; +const phaseCounts: Record = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 }; +const phaseNames: Record = { + '1': 'utilities (non-UI) ', + '2': 'expo-router native patterns ', + '3': '@expo/ui Universal → packages/ui ', + '4': '@expo/ui platform-specific ', + '5': 'no @expo/ui equivalent ', +}; + +// Snapshot total at migration start (update if new components are added to the adapter). +// Count: p1=2, p2=5, p3=10, p4=6, p5=1 = 24 tracked export groups +const TOTAL_AT_START = 24; + +let currentPhase: Phase = '1'; +for (const line of adapterLines) { + const phaseMatch = line.match(/Phase (\d)/); + if (phaseMatch) { + currentPhase = phaseMatch[1] as Phase; + continue; + } + if (line.startsWith('export')) { + phaseCounts[currentPhase]++; + } +} + +const totalRemaining = Object.values(phaseCounts).reduce((a, b) => a + b, 0); +const totalMigrated = TOTAL_AT_START - totalRemaining; +const pct = Math.round((totalMigrated / TOTAL_AT_START) * 100); + +console.log('\n── NativeWindUI → Expo UI migration progress ───────────────────'); +for (const [phase, name] of Object.entries(phaseNames) as [Phase, string][]) { + const count = phaseCounts[phase]; + const status = count === 0 ? '✓ done' : `${count} remaining`; + console.log(` Phase ${phase} ${name} ${status}`); +} +console.log(`\n Overall: ${totalMigrated}/${TOTAL_AT_START} export groups migrated (${pct}%)`); +console.log('─────────────────────────────────────────────────────────────────\n'); + +// ── 2. Scan for direct @packrat-ai/nativewindui imports (adapter bypass) ─── + +const EXCLUDED = new Set(['node_modules', 'dist', 'build', '.expo', '.wrangler']); + +function walk(dir: string): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir)) { + if (EXCLUDED.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...walk(full)); + } else if (/\.(ts|tsx)$/.test(entry)) { + results.push(full); + } + } + return results; +} + +const violations: string[] = []; +for (const file of walk(EXPO_SRC)) { + const content = readFileSync(file, 'utf8'); + if (content.includes("from '@packrat-ai/nativewindui'")) { + const rel = file.replace(ROOT + '/', ''); + const line = + content.split('\n').findIndex((l) => l.includes("from '@packrat-ai/nativewindui'")) + 1; + violations.push(` ${rel}:${line}`); + } +} + +if (violations.length > 0) { + console.error('✗ Direct @packrat-ai/nativewindui imports found (use @packrat/ui/nativewindui):'); + for (const v of violations) console.error(v); + console.error(''); + process.exit(1); +} + +console.log('✓ No adapter bypass violations found.\n');