@@ -6,19 +6,22 @@ import { HorizontalCatalogItemCard } from 'expo-app/features/packs/components/Ho
66import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme' ;
77import { useTranslation } from 'expo-app/lib/hooks/useTranslation' ;
88import { useAtom } from 'jotai' ;
9- import { useMemo , useState } from 'react' ;
9+ import { useCallback , useMemo , useState } from 'react' ;
1010import {
1111 ActivityIndicator ,
1212 FlatList ,
1313 Modal ,
1414 RefreshControl ,
1515 SafeAreaView ,
16+ ScrollView ,
1617 TouchableOpacity ,
1718 View ,
1819} from 'react-native' ;
1920import { useDebounce } from 'use-debounce' ;
2021import { useCatalogItemsInfinite } from '../hooks' ;
2122import { useCatalogItemsCategories } from '../hooks/useCatalogItemsCategories' ;
23+ import { usePopularCatalogItems } from '../hooks/usePopularCatalogItems' ;
24+ import { useRecentlyUsedCatalogItems } from '../hooks/useRecentlyUsedCatalogItems' ;
2225import { useVectorSearch } from '../hooks/useVectorSearch' ;
2326import type { CatalogItem } from '../types' ;
2427
@@ -28,6 +31,118 @@ type CatalogBrowserModalProps = {
2831 onItemsSelected : ( items : CatalogItem [ ] ) => void ;
2932} ;
3033
34+ function QuickAccessSection ( {
35+ title,
36+ items,
37+ selectedItems,
38+ onItemToggle,
39+ emptyMessage,
40+ } : {
41+ title : string ;
42+ items : CatalogItem [ ] ;
43+ selectedItems : Set < number > ;
44+ onItemToggle : ( item : CatalogItem ) => void ;
45+ emptyMessage ?: string ;
46+ } ) {
47+ if ( items . length === 0 ) {
48+ if ( ! emptyMessage ) return null ;
49+ return (
50+ < View className = "mb-4" >
51+ < Text className = "mb-2 text-sm font-semibold text-foreground" > { title } </ Text >
52+ < Text className = "text-xs text-muted-foreground" > { emptyMessage } </ Text >
53+ </ View >
54+ ) ;
55+ }
56+
57+ return (
58+ < View className = "mb-4" >
59+ < Text className = "mb-2 text-sm font-semibold text-foreground" > { title } </ Text >
60+ < ScrollView
61+ horizontal
62+ showsHorizontalScrollIndicator = { false }
63+ contentContainerStyle = { { gap : 8 } }
64+ >
65+ { items . map ( ( item ) => (
66+ < View key = { item . id } className = "w-56" >
67+ < HorizontalCatalogItemCard
68+ item = { item }
69+ selected = { selectedItems . has ( item . id ) }
70+ onSelect = { onItemToggle }
71+ />
72+ </ View >
73+ ) ) }
74+ </ ScrollView >
75+ </ View >
76+ ) ;
77+ }
78+
79+ function SelectedItemsQuantityPanel ( {
80+ items,
81+ quantities,
82+ onQuantityChange,
83+ onClear,
84+ onAdd,
85+ } : {
86+ items : CatalogItem [ ] ;
87+ quantities : Map < number , number > ;
88+ onQuantityChange : ( itemId : number , delta : number ) => void ;
89+ onClear : ( ) => void ;
90+ onAdd : ( ) => void ;
91+ } ) {
92+ const { t } = useTranslation ( ) ;
93+ const { colors } = useColorScheme ( ) ;
94+
95+ return (
96+ < View className = "border-t border-border bg-card" >
97+ < ScrollView style = { { maxHeight : 180 } } contentContainerStyle = { { padding : 12 , gap : 8 } } >
98+ { items . map ( ( item ) => {
99+ const qty = quantities . get ( item . id ) ?? 1 ;
100+ return (
101+ < View key = { item . id } className = "flex-row items-center justify-between" >
102+ < Text className = "flex-1 text-sm text-foreground" numberOfLines = { 1 } >
103+ { item . name }
104+ </ Text >
105+ < View className = "flex-row items-center gap-2 ml-2" >
106+ < TouchableOpacity
107+ onPress = { ( ) => onQuantityChange ( item . id , - 1 ) }
108+ disabled = { qty <= 1 }
109+ className = "h-7 w-7 items-center justify-center rounded-full border border-border"
110+ >
111+ < Icon
112+ name = "minus"
113+ size = { 14 }
114+ color = { qty <= 1 ? colors . grey2 : colors . foreground }
115+ />
116+ </ TouchableOpacity >
117+ < Text className = "w-6 text-center text-sm font-medium text-foreground" > { qty } </ Text >
118+ < TouchableOpacity
119+ onPress = { ( ) => onQuantityChange ( item . id , 1 ) }
120+ className = "h-7 w-7 items-center justify-center rounded-full border border-border"
121+ >
122+ < Icon name = "plus" size = { 14 } color = { colors . foreground } />
123+ </ TouchableOpacity >
124+ </ View >
125+ </ View >
126+ ) ;
127+ } ) }
128+ </ ScrollView >
129+ < View className = "flex-row gap-3 items-center justify-end border-t border-border px-4 py-3" >
130+ < Button variant = "secondary" className = "mb-1" onPress = { onClear } >
131+ < Text > { t ( 'catalog.clearSelection' ) } </ Text >
132+ </ Button >
133+ < Button onPress = { onAdd } className = "mb-1" variant = "tonal" >
134+ < Text >
135+ { t ( 'catalog.addItems' , {
136+ count : items . length ,
137+ unit : items . length > 1 ? t ( 'catalog.items' ) : t ( 'catalog.item' ) ,
138+ } ) }
139+ </ Text >
140+ </ Button >
141+ </ View >
142+ </ View >
143+ ) ;
144+ }
145+
31146export function CatalogBrowserModal ( {
32147 visible,
33148 onClose,
@@ -38,11 +153,17 @@ export function CatalogBrowserModal({
38153 const [ searchValue , setSearchValue ] = useAtom ( searchValueAtom ) ;
39154 const [ activeFilter , setActiveFilter ] = useState < 'All' | string > ( 'All' ) ;
40155 const [ selectedItems , setSelectedItems ] = useState < Set < number > > ( new Set ( ) ) ;
156+ const [ itemQuantities , setItemQuantities ] = useState < Map < number , number > > ( new Map ( ) ) ;
41157 const [ debouncedSearchValue ] = useDebounce ( searchValue , 400 ) ;
42158
43159 const isSearching = debouncedSearchValue . length > 0 ;
160+ const isDefaultView = ! isSearching && activeFilter === 'All' ;
44161
45162 const { data : categories } = useCatalogItemsCategories ( ) ;
163+ const { recentItems } = useRecentlyUsedCatalogItems ( ) ;
164+ const { data : popularData , isLoading : isPopularLoading } = usePopularCatalogItems ( 8 ) ;
165+
166+ const popularItems = popularData ?. items ?? [ ] ;
46167
47168 const {
48169 data : paginatedData ,
@@ -71,32 +192,65 @@ export function CatalogBrowserModal({
71192 const isLoading = isSearching ? isSearchLoading : isPaginatedLoading ;
72193 const error = isSearching ? searchError : paginatedError ;
73194
74- const handleItemToggle = ( item : CatalogItem ) => {
75- const newSelected = new Set ( selectedItems ) ;
76- if ( newSelected . has ( item . id ) ) {
77- newSelected . delete ( item . id ) ;
78- } else {
79- newSelected . add ( item . id ) ;
80- }
81- setSelectedItems ( newSelected ) ;
82- } ;
195+ // All unique items available (main list + popular + recent) for lookup
196+ const allAvailableItems = useMemo ( ( ) => {
197+ const map = new Map < number , CatalogItem > ( ) ;
198+ for ( const item of items ) map . set ( item . id , item ) ;
199+ for ( const item of popularItems ) map . set ( item . id , item ) ;
200+ for ( const item of recentItems ) map . set ( item . id , item ) ;
201+ return map ;
202+ } , [ items , popularItems , recentItems ] ) ;
203+
204+ const handleItemToggle = useCallback (
205+ ( item : CatalogItem ) => {
206+ const newSelected = new Set ( selectedItems ) ;
207+ if ( newSelected . has ( item . id ) ) {
208+ newSelected . delete ( item . id ) ;
209+ setItemQuantities ( ( prev ) => {
210+ const next = new Map ( prev ) ;
211+ next . delete ( item . id ) ;
212+ return next ;
213+ } ) ;
214+ } else {
215+ newSelected . add ( item . id ) ;
216+ setItemQuantities ( ( prev ) => new Map ( prev ) . set ( item . id , 1 ) ) ;
217+ }
218+ setSelectedItems ( newSelected ) ;
219+ } ,
220+ [ selectedItems ] ,
221+ ) ;
222+
223+ const handleQuantityChange = useCallback ( ( itemId : number , delta : number ) => {
224+ setItemQuantities ( ( prev ) => {
225+ const current = prev . get ( itemId ) ?? 1 ;
226+ const next = Math . max ( 1 , current + delta ) ;
227+ return new Map ( prev ) . set ( itemId , next ) ;
228+ } ) ;
229+ } , [ ] ) ;
83230
84231 const handleAddSelected = ( ) => {
85- const selectedCatalogItems = items . filter ( ( item : CatalogItem ) => selectedItems . has ( item . id ) ) ;
232+ const selectedCatalogItems = Array . from ( selectedItems )
233+ . map ( ( id ) => allAvailableItems . get ( id ) )
234+ . filter ( ( item ) : item is CatalogItem => item !== undefined )
235+ . map ( ( item ) => ( { ...item , quantity : itemQuantities . get ( item . id ) ?? 1 } ) ) ;
86236 onItemsSelected ( selectedCatalogItems ) ;
87- setSelectedItems ( new Set ( ) ) ;
237+ resetSelection ( ) ;
88238 onClose ( ) ;
89239 } ;
90240
91- const handleClose = ( ) => {
241+ const resetSelection = ( ) => {
92242 setSelectedItems ( new Set ( ) ) ;
243+ setItemQuantities ( new Map ( ) ) ;
244+ } ;
245+
246+ const handleClose = ( ) => {
247+ resetSelection ( ) ;
93248 setSearchValue ( '' ) ;
94249 onClose ( ) ;
95250 } ;
96251
97252 const handleRefresh = ( ) => {
98253 if ( isSearching ) {
99- // For search, we can't really refresh, so just clear search
100254 setSearchValue ( '' ) ;
101255 } else {
102256 refetch ( ) ;
@@ -109,6 +263,14 @@ export function CatalogBrowserModal({
109263 }
110264 } ;
111265
266+ const selectedItemsList = useMemo (
267+ ( ) =>
268+ Array . from ( selectedItems )
269+ . map ( ( id ) => allAvailableItems . get ( id ) )
270+ . filter ( ( item ) : item is CatalogItem => item !== undefined ) ,
271+ [ selectedItems , allAvailableItems ] ,
272+ ) ;
273+
112274 const renderItem = ( { item } : { item : CatalogItem } ) => (
113275 < HorizontalCatalogItemCard
114276 item = { item }
@@ -119,6 +281,44 @@ export function CatalogBrowserModal({
119281
120282 const ItemSeparatorComponent = useMemo ( ( ) => ( ) => < View className = "h-2" /> , [ ] ) ;
121283
284+ const ListHeaderComponent = useMemo ( ( ) => {
285+ if ( ! isDefaultView ) return null ;
286+ const showPopular = popularItems . length > 0 || isPopularLoading ;
287+ const showRecent = recentItems . length > 0 ;
288+ if ( ! showPopular && ! showRecent ) return null ;
289+ return (
290+ < View className = "pb-2" >
291+ { showRecent && (
292+ < QuickAccessSection
293+ title = { t ( 'catalog.recentlyUsed' ) }
294+ items = { recentItems }
295+ selectedItems = { selectedItems }
296+ onItemToggle = { handleItemToggle }
297+ />
298+ ) }
299+ { showPopular && (
300+ < QuickAccessSection
301+ title = { t ( 'catalog.popularItems' ) }
302+ items = { isPopularLoading ? [ ] : popularItems }
303+ selectedItems = { selectedItems }
304+ onItemToggle = { handleItemToggle }
305+ />
306+ ) }
307+ < Text className = "mb-2 text-sm font-semibold text-foreground" >
308+ { t ( 'catalog.browseCatalog' ) }
309+ </ Text >
310+ </ View >
311+ ) ;
312+ } , [
313+ isDefaultView ,
314+ popularItems ,
315+ isPopularLoading ,
316+ recentItems ,
317+ selectedItems ,
318+ handleItemToggle ,
319+ t ,
320+ ] ) ;
321+
122322 return (
123323 < Modal visible = { visible } animationType = "slide" presentationStyle = "pageSheet" >
124324 < SafeAreaView className = "flex-1 bg-background" >
@@ -171,7 +371,7 @@ export function CatalogBrowserModal({
171371 < Text > { t ( 'catalog.tryAgain' ) } </ Text >
172372 </ Button >
173373 </ View >
174- ) : items . length === 0 ? (
374+ ) : items . length === 0 && ! isDefaultView ? (
175375 < View className = "flex-1 items-center justify-center p-4" >
176376 < Icon name = "magnify" size = { 48 } color = { colors . grey2 } />
177377 < Text className = "mt-2 text-center font-semibold" > { t ( 'catalog.noItemsFound' ) } </ Text >
@@ -186,6 +386,7 @@ export function CatalogBrowserModal({
186386 renderItem = { renderItem }
187387 contentContainerStyle = { { padding : 16 } }
188388 ItemSeparatorComponent = { ItemSeparatorComponent }
389+ ListHeaderComponent = { ListHeaderComponent }
189390 refreshControl = {
190391 < RefreshControl
191392 refreshing = { isRefetching }
@@ -206,27 +407,15 @@ export function CatalogBrowserModal({
206407 ) }
207408 </ View >
208409
209- { /* Bottom Actions */ }
410+ { /* Bottom Actions with per-item quantity */ }
210411 { selectedItems . size > 0 && (
211- < View className = "border-t border-border bg-card p-4" >
212- < View className = "flex-row gap-3 items-center justify-end" >
213- < Button
214- variant = "secondary"
215- className = "mb-1"
216- onPress = { ( ) => setSelectedItems ( new Set ( ) ) }
217- >
218- < Text > { t ( 'catalog.clearSelection' ) } </ Text >
219- </ Button >
220- < Button onPress = { handleAddSelected } className = "mb-1" variant = "tonal" >
221- < Text >
222- { t ( 'catalog.addItems' , {
223- count : selectedItems . size ,
224- unit : selectedItems . size > 1 ? t ( 'catalog.items' ) : t ( 'catalog.item' ) ,
225- } ) }
226- </ Text >
227- </ Button >
228- </ View >
229- </ View >
412+ < SelectedItemsQuantityPanel
413+ items = { selectedItemsList }
414+ quantities = { itemQuantities }
415+ onQuantityChange = { handleQuantityChange }
416+ onClear = { resetSelection }
417+ onAdd = { handleAddSelected }
418+ />
230419 ) }
231420 </ SafeAreaView >
232421 </ Modal >
0 commit comments