Add catalog/collection reorder, enable/disable toggle, and fix catalog name display

- Add CatalogOrderScreen for reordering catalogs and collections on home screen
- Add catalogOrderService for persisting display order in MMKV
- Add enable/disable toggle for collections in CollectionManagementScreen
- Fix catalog name display in FolderDetailScreen tabs and CollectionEditorScreen
- Filter disabled collections from reorder screen and home screen
- Integrate display order into HomeScreen listData memo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Amarjit Singh 2026-03-22 23:17:18 -07:00
parent 611a6b49e9
commit 2262889479
10 changed files with 637 additions and 53 deletions

View file

@ -4,12 +4,17 @@ import { collectionsService, collectionsEmitter, COLLECTIONS_EVENTS } from '../s
export function useCollections() {
const [collections, setCollections] = useState<Collection[]>([]);
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const loadCollections = useCallback(async () => {
try {
const data = await collectionsService.getCollections();
const [data, settings] = await Promise.all([
collectionsService.getCollections(),
collectionsService.getCollectionSettings(),
]);
setCollections(data);
setEnabledMap(settings);
} catch (error) {
if (__DEV__) console.error('[useCollections] Error loading:', error);
} finally {
@ -31,5 +36,5 @@ export function useCollections() {
};
}, [loadCollections]);
return { collections, loading, refresh: loadCollections };
return { collections, enabledMap, loading, refresh: loadCollections };
}

View file

@ -80,6 +80,7 @@ import ContributorsScreen from '../screens/ContributorsScreen';
import CollectionManagementScreen from '../screens/CollectionManagementScreen';
import CollectionEditorScreen from '../screens/CollectionEditorScreen';
import FolderDetailScreen from '../screens/FolderDetailScreen';
import CatalogOrderScreen from '../screens/CatalogOrderScreen';
import {
ContentDiscoverySettingsScreen,
@ -230,6 +231,7 @@ export type RootStackParamList = {
Collections: undefined;
CollectionEditor: { collectionId?: string };
FolderDetail: { collectionId: string; folderId: string };
CatalogOrder: undefined;
// New organized settings screens
ContentDiscoverySettings: undefined;
@ -1939,6 +1941,18 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
<Stack.Screen
name="CatalogOrder"
component={CatalogOrderScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>

View file

@ -0,0 +1,350 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
StatusBar,
} from 'react-native';
import { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useCollections } from '../hooks/useCollections';
import { stremioService } from '../services/stremioService';
import { mmkvStorage } from '../services/mmkvStorage';
import { catalogOrderService, CatalogOrderService } from '../services/catalogOrderService';
import ScreenHeader from '../components/common/ScreenHeader';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { RootStackParamList } from '../navigation/AppNavigator';
interface OrderItem {
key: string;
label: string;
subtitle: string;
isCollection: boolean;
}
const CATALOG_SETTINGS_KEY = 'catalog_settings';
const CatalogOrderScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const { collections, enabledMap: collectionEnabledMap } = useCollections();
const [orderedKeys, setOrderedKeys] = useState<string[]>([]);
const [catalogItems, setCatalogItems] = useState<OrderItem[]>([]);
const [loading, setLoading] = useState(true);
// Build collection items from hook
const collectionItems = useMemo(() => {
const items: OrderItem[] = [];
for (const collection of collections) {
if (collectionEnabledMap[collection.id] === false) continue;
items.push({
key: CatalogOrderService.collectionKey(collection.id),
label: collection.title,
subtitle: `${collection.folders.length} folder${collection.folders.length !== 1 ? 's' : ''}`,
isCollection: true,
});
}
return items;
}, [collections]);
// Build the full item map
const itemMap = useMemo(() => {
const map = new Map<string, OrderItem>();
for (const item of collectionItems) map.set(item.key, item);
for (const item of catalogItems) map.set(item.key, item);
return map;
}, [collectionItems, catalogItems]);
// Load catalog metadata from installed addons (same filtering as HomeScreen)
const loadCatalogs = useCallback(async () => {
try {
const addons = await stremioService.getInstalledAddonsAsync();
const savedSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings: Record<string, boolean> = savedSettingsJson ? JSON.parse(savedSettingsJson) : {};
const items: OrderItem[] = [];
const seen = new Set<string>();
for (const addon of addons) {
if (!addon.catalogs) continue;
const addonUsesShowInHome = addon.catalogs.some((c: any) => c.showInHome === true);
for (const catalog of addon.catalogs) {
// Skip search catalogs
if (
(catalog.id && catalog.id.startsWith('search.')) ||
(catalog.type && catalog.type.startsWith('search'))
) continue;
// Skip catalogs with required extras
const requiredExtras = (catalog.extra || []).filter((e: any) => e.isRequired);
if (requiredExtras.length > 0) continue;
// Respect showInHome flag
if (addonUsesShowInHome && !(catalog as any).showInHome) continue;
// Respect user enable/disable setting
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
const isEnabled = catalogSettings[settingKey] ?? true;
if (!isEnabled) continue;
// Deduplicate
const key = CatalogOrderService.catalogKey(addon.id, catalog.type, catalog.id);
if (seen.has(key)) continue;
seen.add(key);
items.push({
key,
label: catalog.name || catalog.id,
subtitle: `${addon.name || addon.id} · ${catalog.type}`,
isCollection: false,
});
}
}
setCatalogItems(items);
} catch {
// silent fail
}
}, []);
// Load everything on focus
useFocusEffect(
useCallback(() => {
setLoading(true);
Promise.all([
loadCatalogs(),
catalogOrderService.getOrder(),
]).then(([_, savedOrder]) => {
// itemMap might not be updated yet, so we'll set keys and let the
// ordered list rebuild via the items memo below
setOrderedKeys(savedOrder);
setLoading(false);
});
}, [loadCatalogs])
);
// Merge saved order with available items
const items = useMemo(() => {
const allItems = [...collectionItems, ...catalogItems];
const allMap = new Map<string, OrderItem>();
for (const item of allItems) allMap.set(item.key, item);
if (orderedKeys.length === 0) return allItems;
const result: OrderItem[] = [];
const seen = new Set<string>();
// First: items in saved order
for (const key of orderedKeys) {
const item = allMap.get(key);
if (item && !seen.has(key)) {
result.push(item);
seen.add(key);
}
}
// Then: any new items not in saved order
for (const item of allItems) {
if (!seen.has(item.key)) {
result.push(item);
}
}
return result;
}, [orderedKeys, collectionItems, catalogItems]);
const handleMoveUp = useCallback((index: number) => {
if (index <= 0) return;
const currentKeys = items.map(i => i.key);
[currentKeys[index - 1], currentKeys[index]] = [currentKeys[index], currentKeys[index - 1]];
setOrderedKeys(currentKeys);
catalogOrderService.saveOrder(currentKeys);
}, [items]);
const handleMoveDown = useCallback((index: number) => {
if (index >= items.length - 1) return;
const currentKeys = items.map(i => i.key);
[currentKeys[index], currentKeys[index + 1]] = [currentKeys[index + 1], currentKeys[index]];
setOrderedKeys(currentKeys);
catalogOrderService.saveOrder(currentKeys);
}, [items]);
const handleReset = useCallback(async () => {
await catalogOrderService.resetOrder();
setOrderedKeys([]);
}, []);
if (loading) {
return (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<ScreenHeader title="Display Order" showBackButton onBackPress={() => navigation.goBack()} />
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" />
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<ScreenHeader
title="Display Order"
showBackButton
onBackPress={() => navigation.goBack()}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
<TouchableOpacity
style={[styles.resetButton, { backgroundColor: colors.elevation3 }]}
activeOpacity={0.7}
onPress={handleReset}
>
<MaterialIcons name="refresh" size={18} color={colors.text} />
<Text style={[styles.resetButtonText, { color: colors.text }]}>Reset to Default</Text>
</TouchableOpacity>
{items.length === 0 && (
<View style={styles.emptyContainer}>
<MaterialIcons name="reorder" size={48} color={colors.textMuted} />
<Text style={[styles.emptyText, { color: colors.textMuted }]}>
No catalogs or collections to reorder
</Text>
</View>
)}
{items.map((item, index) => (
<View
key={item.key}
style={[
styles.itemCard,
{
backgroundColor: colors.elevation1,
borderColor: item.isCollection ? colors.primary + '40' : colors.border,
},
]}
>
<View style={styles.itemInfo}>
<MaterialIcons
name={item.isCollection ? 'folder' : 'view-list'}
size={20}
color={item.isCollection ? colors.primary : colors.textMuted}
/>
<View style={styles.itemTextContainer}>
<Text style={[styles.itemLabel, { color: colors.text }]} numberOfLines={1}>
{item.label}
</Text>
<Text style={[styles.itemSubtitle, { color: colors.textMuted }]} numberOfLines={1}>
{item.subtitle}
</Text>
</View>
</View>
<View style={styles.itemActions}>
<TouchableOpacity
onPress={() => handleMoveUp(index)}
disabled={index === 0}
style={[styles.iconButton, index === 0 && styles.disabledButton]}
>
<MaterialIcons
name="arrow-upward"
size={20}
color={index === 0 ? colors.disabled : colors.text}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMoveDown(index)}
disabled={index === items.length - 1}
style={[styles.iconButton, index === items.length - 1 && styles.disabledButton]}
>
<MaterialIcons
name="arrow-downward"
size={20}
color={index === items.length - 1 ? colors.disabled : colors.text}
/>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
scrollContent: {
padding: 16,
paddingBottom: 40,
},
resetButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
borderRadius: 10,
marginBottom: 16,
gap: 6,
},
resetButtonText: {
fontSize: 14,
fontWeight: '500',
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
gap: 12,
},
emptyText: {
fontSize: 15,
},
itemCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 12,
borderRadius: 10,
borderWidth: 1,
marginBottom: 8,
},
itemInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: 10,
},
itemTextContainer: {
flex: 1,
},
itemLabel: {
fontSize: 15,
fontWeight: '500',
},
itemSubtitle: {
fontSize: 12,
marginTop: 2,
},
itemActions: {
flexDirection: 'row',
gap: 2,
},
iconButton: {
padding: 6,
},
disabledButton: {
opacity: 0.3,
},
});
export default CatalogOrderScreen;

View file

@ -407,6 +407,9 @@ const CollectionEditorScreen = () => {
{editingFolder.catalogSources.map((source, idx) => {
const isMissing = !installedAddonIds.has(source.addonId);
const catalogInfo = availableCatalogs.find(
c => c.addonId === source.addonId && c.type === source.type && c.catalogId === source.catalogId
);
return (
<View
key={`${source.addonId}-${source.type}-${source.catalogId}`}
@ -420,7 +423,7 @@ const CollectionEditorScreen = () => {
>
<View style={styles.catalogSourceInfo}>
<Text style={[styles.catalogSourceName, { color: isMissing ? colors.error : colors.text }]} numberOfLines={1}>
{source.catalogId}
{catalogInfo?.name || source.catalogId}
</Text>
<Text style={[styles.catalogSourceMeta, { color: isMissing ? colors.error : colors.textMuted }]}>
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}

View file

@ -6,6 +6,7 @@ import {
TouchableOpacity,
ScrollView,
StatusBar,
Switch,
Platform,
Alert,
} from 'react-native';
@ -36,13 +37,25 @@ const CollectionManagementScreen = () => {
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<any[]>([]);
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
const loadEnabledState = useCallback(async () => {
const settings = await collectionsService.getCollectionSettings();
setEnabledMap(settings);
}, []);
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh])
loadEnabledState();
}, [refresh, loadEnabledState])
);
const handleToggleEnabled = useCallback(async (id: string, value: boolean) => {
setEnabledMap(prev => ({ ...prev, [id]: value }));
await collectionsService.setCollectionEnabled(id, value);
}, []);
const handleNewCollection = useCallback(() => {
navigation.navigate('CollectionEditor' as any, {});
}, [navigation]);
@ -151,45 +164,62 @@ const CollectionManagementScreen = () => {
</View>
)}
{collections.map((collection, index) => (
<View
key={collection.id}
style={[styles.collectionCard, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
>
<View style={styles.cardHeader}>
<View style={styles.cardTitleRow}>
<Text style={[styles.collectionTitle, { color: colors.text }]} numberOfLines={1}>
{collection.title}
</Text>
<Text style={[styles.folderCount, { color: colors.textMuted }]}>
{collection.folders.length} folder{collection.folders.length !== 1 ? 's' : ''}
</Text>
</View>
<View style={styles.cardActions}>
<TouchableOpacity
onPress={() => handleMoveUp(collection.id)}
disabled={index === 0}
style={[styles.iconButton, index === 0 && styles.disabledButton]}
>
<MaterialIcons name="arrow-upward" size={20} color={index === 0 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMoveDown(collection.id)}
disabled={index === collections.length - 1}
style={[styles.iconButton, index === collections.length - 1 && styles.disabledButton]}
>
<MaterialIcons name="arrow-downward" size={20} color={index === collections.length - 1 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleEdit(collection.id)} style={styles.iconButton}>
<MaterialIcons name="edit" size={20} color={colors.primary} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(collection)} style={styles.iconButton}>
<MaterialIcons name="delete-outline" size={20} color={colors.error} />
</TouchableOpacity>
{collections.map((collection, index) => {
const isEnabled = enabledMap[collection.id] !== false;
return (
<View
key={collection.id}
style={[
styles.collectionCard,
{
backgroundColor: colors.elevation1,
borderColor: isEnabled ? colors.border : colors.border + '60',
opacity: isEnabled ? 1 : 0.6,
},
]}
>
<View style={styles.cardHeader}>
<View style={styles.cardTitleRow}>
<Text style={[styles.collectionTitle, { color: colors.text }]} numberOfLines={1}>
{collection.title}
</Text>
<Text style={[styles.folderCount, { color: colors.textMuted }]}>
{collection.folders.length} folder{collection.folders.length !== 1 ? 's' : ''}{!isEnabled ? ' · Hidden' : ''}
</Text>
</View>
<View style={styles.cardActions}>
<Switch
value={isEnabled}
onValueChange={(value) => handleToggleEnabled(collection.id, value)}
trackColor={{ false: colors.elevation3, true: colors.primary + '80' }}
thumbColor={isEnabled ? colors.primary : colors.textMuted}
style={styles.switch}
/>
<TouchableOpacity
onPress={() => handleMoveUp(collection.id)}
disabled={index === 0}
style={[styles.iconButton, index === 0 && styles.disabledButton]}
>
<MaterialIcons name="arrow-upward" size={20} color={index === 0 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMoveDown(collection.id)}
disabled={index === collections.length - 1}
style={[styles.iconButton, index === collections.length - 1 && styles.disabledButton]}
>
<MaterialIcons name="arrow-downward" size={20} color={index === collections.length - 1 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleEdit(collection.id)} style={styles.iconButton}>
<MaterialIcons name="edit" size={20} color={colors.primary} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(collection)} style={styles.iconButton}>
<MaterialIcons name="delete-outline" size={20} color={colors.error} />
</TouchableOpacity>
</View>
</View>
</View>
</View>
))}
);
})}
{collections.length === 0 && (
<View style={styles.importOnlyRow}>
@ -302,6 +332,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: 4,
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
},
iconButton: {
padding: 6,
},

View file

@ -55,11 +55,18 @@ const FolderDetailScreen = () => {
if (!f) return;
setFolder(f);
// Build tab labels
const labels = f.catalogSources.map(source => ({
name: source.catalogId,
type: source.type,
}));
// Build tab labels — resolve human-readable names from addon manifests
const addons = await stremioService.getInstalledAddonsAsync();
const labels = f.catalogSources.map(source => {
const addon = addons.find((a: any) => a.id === source.addonId);
const catalog = addon?.catalogs?.find(
(c: any) => c.id === source.catalogId && c.type === source.type
);
return {
name: catalog?.name || source.catalogId,
type: source.type,
};
});
setTabLabels(labels);
// Load first tab

View file

@ -74,6 +74,7 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext';
import CollectionRowSection from '../components/home/CollectionRowSection';
import { useCollections } from '../hooks/useCollections';
import { collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService';
import { catalogOrderService, catalogOrderEmitter, CATALOG_ORDER_EVENTS, CatalogOrderService } from '../services/catalogOrderService';
// Constants
const CATALOG_SETTINGS_KEY = 'catalog_settings';
@ -147,7 +148,8 @@ const HomeScreen = () => {
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
const { showInfo } = useToast();
const { setTrailerPlaying } = useTrailer();
const { collections } = useCollections();
const { collections, enabledMap: collectionEnabledMap } = useCollections();
const [savedOrder, setSavedOrder] = useState<string[]>([]);
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -196,6 +198,14 @@ const HomeScreen = () => {
return () => clearTimeout(timer);
}, [insets.top]);
// Load saved catalog/collection display order
useEffect(() => {
catalogOrderService.getOrder().then(setSavedOrder);
const handler = () => catalogOrderService.getOrder().then(setSavedOrder);
catalogOrderEmitter.on(CATALOG_ORDER_EVENTS.CHANGED, handler);
return () => { catalogOrderEmitter.off(CATALOG_ORDER_EVENTS.CHANGED, handler); };
}, []);
const {
featuredContent,
allFeaturedContent,
@ -763,26 +773,74 @@ const HomeScreen = () => {
// Only show a limited number of catalogs initially for performance
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
// Show collections at the top, before catalog rows
// Build maps of available items keyed for ordering (filter out disabled collections)
const collectionMap = new Map<string, import('../types/collections').Collection>();
for (const collection of collections) {
data.push({ type: 'collection', collection, key: `collection-${collection.id}` });
if (collectionEnabledMap[collection.id] === false) continue;
collectionMap.set(CatalogOrderService.collectionKey(collection.id), collection);
}
const catalogMap = new Map<string, { catalog: CatalogContent; index: number }>();
catalogsToShow.forEach((catalog, index) => {
if (catalog) {
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
} else if (catalogsLoading && pendingCatalogIndexes[index]) {
data.push({ type: 'placeholder', key: `placeholder-${index}` });
catalogMap.set(CatalogOrderService.catalogKey(catalog.addon, catalog.type, catalog.id), { catalog, index });
}
});
// Apply saved order if available
if (savedOrder.length > 0) {
const seen = new Set<string>();
for (const key of savedOrder) {
if (seen.has(key)) continue;
seen.add(key);
if (collectionMap.has(key)) {
const col = collectionMap.get(key)!;
data.push({ type: 'collection', collection: col, key: `collection-${col.id}` });
} else if (catalogMap.has(key)) {
const { catalog, index } = catalogMap.get(key)!;
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
}
}
// Append any items not in the saved order
for (const [key, col] of collectionMap) {
if (!seen.has(key)) {
data.push({ type: 'collection', collection: col, key: `collection-${col.id}` });
}
}
for (const [key, { catalog, index }] of catalogMap) {
if (!seen.has(key)) {
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
}
}
} else {
// Default order: enabled collections first, then catalogs
for (const collection of collections) {
if (collectionEnabledMap[collection.id] === false) continue;
data.push({ type: 'collection', collection, key: `collection-${collection.id}` });
}
catalogsToShow.forEach((catalog, index) => {
if (catalog) {
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
}
});
}
// Show placeholders for loading catalogs (not part of ordering)
if (catalogsLoading) {
catalogsToShow.forEach((catalog, index) => {
if (!catalog && pendingCatalogIndexes[index]) {
data.push({ type: 'placeholder', key: `placeholder-${index}` });
}
});
}
// Add a "Load More" button if there are more catalogs to show
if (catalogs.length > visibleCatalogCount && catalogs.filter(c => c).length > visibleCatalogCount) {
data.push({ type: 'loadMore', key: 'load-more' });
}
return data;
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections]);
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections, collectionEnabledMap, savedOrder]);
const handleLoadMoreCatalogs = useCallback(() => {
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));

View file

@ -128,6 +128,14 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
onPress={() => navigation.navigate('Collections')}
isTablet={isTablet}
/>
<SettingItem
title="Display Order"
description="Reorder catalogs and collections on home"
icon="menu"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('CatalogOrder')}
isTablet={isTablet}
/>
{isItemVisible('home_screen') && (
<SettingItem
title={t('settings.items.home_screen')}

View file

@ -0,0 +1,72 @@
import { mmkvStorage } from './mmkvStorage';
import EventEmitter from 'eventemitter3';
const ORDER_STORAGE_KEY = 'catalog_display_order';
export const catalogOrderEmitter = new EventEmitter();
export const CATALOG_ORDER_EVENTS = {
CHANGED: 'catalog_order_changed',
} as const;
export class CatalogOrderService {
private static instance: CatalogOrderService;
private constructor() {}
static getInstance(): CatalogOrderService {
if (!CatalogOrderService.instance) {
CatalogOrderService.instance = new CatalogOrderService();
}
return CatalogOrderService.instance;
}
private async getStorageKey(): Promise<string> {
const scope = await mmkvStorage.getItem('@user:current') || 'local';
return `@user:${scope}:${ORDER_STORAGE_KEY}`;
}
async getOrder(): Promise<string[]> {
try {
const key = await this.getStorageKey();
const json = await mmkvStorage.getItem(key);
if (!json) return [];
const parsed = JSON.parse(json);
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
}
async saveOrder(order: string[]): Promise<void> {
try {
const key = await this.getStorageKey();
await mmkvStorage.setItem(key, JSON.stringify(order));
catalogOrderEmitter.emit(CATALOG_ORDER_EVENTS.CHANGED);
} catch {
// silent fail
}
}
async resetOrder(): Promise<void> {
try {
const key = await this.getStorageKey();
await mmkvStorage.setItem(key, JSON.stringify([]));
catalogOrderEmitter.emit(CATALOG_ORDER_EVENTS.CHANGED);
} catch {
// silent fail
}
}
/** Build a catalog key from addon/type/id */
static catalogKey(addonId: string, type: string, catalogId: string): string {
return `${addonId}:${type}:${catalogId}`;
}
/** Build a collection key from collection id */
static collectionKey(collectionId: string): string {
return `collection_${collectionId}`;
}
}
export const catalogOrderService = CatalogOrderService.getInstance();

View file

@ -4,6 +4,7 @@ import { logger } from '../utils/logger';
import EventEmitter from 'eventemitter3';
const COLLECTIONS_STORAGE_KEY = 'collections';
const COLLECTION_SETTINGS_KEY = 'collection_settings';
export const collectionsEmitter = new EventEmitter();
export const COLLECTIONS_EVENTS = {
@ -120,6 +121,39 @@ class CollectionsService {
}
}
private async getSettingsKey(): Promise<string> {
const scope = await mmkvStorage.getItem('@user:current') || 'local';
return `@user:${scope}:${COLLECTION_SETTINGS_KEY}`;
}
async getCollectionSettings(): Promise<Record<string, boolean>> {
try {
const key = await this.getSettingsKey();
const json = await mmkvStorage.getItem(key);
if (!json) return {};
return JSON.parse(json);
} catch {
return {};
}
}
async isCollectionEnabled(id: string): Promise<boolean> {
const settings = await this.getCollectionSettings();
return settings[id] !== false; // default enabled
}
async setCollectionEnabled(id: string, enabled: boolean): Promise<void> {
try {
const settings = await this.getCollectionSettings();
settings[id] = enabled;
const key = await this.getSettingsKey();
await mmkvStorage.setItem(key, JSON.stringify(settings));
collectionsEmitter.emit(COLLECTIONS_EVENTS.CHANGED);
} catch {
// silent fail
}
}
generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}