mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-19 05:47:43 -04:00
Add folder scaffolding (#1404)
This commit is contained in:
@@ -21,6 +21,14 @@ export default function ItemsLayout(): React.ReactNode {
|
||||
...defaultHeaderOptions,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="folder/[id]"
|
||||
options={{
|
||||
title: t('items.folders.folder'),
|
||||
headerBackTitle: t('items.title'),
|
||||
...defaultHeaderOptions,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="add-edit"
|
||||
options={{
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, View, Alert, Keyboard, Platform, ScrollView, KeyboardAvoidingView } from 'react-native';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
import type { Folder } from '@/utils/db/repositories/FolderRepository';
|
||||
import { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, Identity, IdentityHelperUtils, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator';
|
||||
import type { Attachment, Item, ItemField, TotpCode, ItemType, FieldType } from '@/utils/dist/core/models/vault';
|
||||
import { ItemTypes, getSystemFieldsForItemType, getOptionalFieldsForItemType, isFieldShownByDefault, getSystemField, fieldAppliesToType, FieldCategories, FieldTypes } from '@/utils/dist/core/models/vault';
|
||||
@@ -20,6 +21,7 @@ import { extractServiceNameFromUrl } from '@/utils/UrlUtility';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useVaultMutate } from '@/hooks/useVaultMutate';
|
||||
|
||||
import { FolderSelector } from '@/components/folders/FolderSelector';
|
||||
import { AddFieldMenu, type OptionalSection } from '@/components/form/AddFieldMenu';
|
||||
import { AdvancedPasswordField } from '@/components/form/AdvancedPasswordField';
|
||||
import { EmailDomainField } from '@/components/form/EmailDomainField';
|
||||
@@ -97,6 +99,9 @@ export default function AddEditItemScreen(): React.ReactNode {
|
||||
const [show2FA, setShow2FA] = useState(false);
|
||||
const [showAttachments, setShowAttachments] = useState(false);
|
||||
|
||||
// Folder state
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
|
||||
// Track manually added optional fields
|
||||
const [manuallyAddedFields, setManuallyAddedFields] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -488,6 +493,14 @@ export default function AddEditItemScreen(): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load folders for folder selection
|
||||
try {
|
||||
const loadedFolders = await dbContext.sqliteClient!.getAllFolders();
|
||||
setFolders(loadedFolders);
|
||||
} catch (err) {
|
||||
console.error('Error loading folders:', err);
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
loadExistingItem();
|
||||
} else {
|
||||
@@ -534,7 +547,7 @@ export default function AddEditItemScreen(): React.ReactNode {
|
||||
};
|
||||
|
||||
initializeComponent();
|
||||
}, [id, isEditMode, serviceUrl, itemTypeParam, loadExistingItem, authContext.isOffline, router, t]);
|
||||
}, [id, isEditMode, serviceUrl, itemTypeParam, loadExistingItem, authContext.isOffline, router, t, dbContext.sqliteClient]);
|
||||
|
||||
/**
|
||||
* Auto-generate alias when alias fields are shown by default in create mode.
|
||||
@@ -1257,6 +1270,17 @@ export default function AddEditItemScreen(): React.ReactNode {
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{/* Folder selection */}
|
||||
{folders.length > 0 && (
|
||||
<FolderSelector
|
||||
folders={folders}
|
||||
selectedFolderId={item.FolderId}
|
||||
onFolderChange={(folderId) => {
|
||||
setItem(prev => prev ? { ...prev, FolderId: folderId } : prev);
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
{/* Passkey Section - only in edit mode for items with passkeys */}
|
||||
|
||||
562
apps/mobile-app/app/(tabs)/items/folder/[id].tsx
Normal file
562
apps/mobile-app/app/(tabs)/items/folder/[id].tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, StyleSheet, Platform, View, Text, TextInput, TouchableOpacity, RefreshControl, FlatList } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
import type { Folder } from '@/utils/db/repositories/FolderRepository';
|
||||
import type { Item } from '@/utils/dist/core/models/vault';
|
||||
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
import { useVaultMutate } from '@/hooks/useVaultMutate';
|
||||
import { useVaultSync } from '@/hooks/useVaultSync';
|
||||
|
||||
import { DeleteFolderModal } from '@/components/folders/DeleteFolderModal';
|
||||
import { FolderModal } from '@/components/folders/FolderModal';
|
||||
import { ItemCard } from '@/components/items/ItemCard';
|
||||
import LoadingOverlay from '@/components/LoadingOverlay';
|
||||
import { ThemedContainer } from '@/components/themed/ThemedContainer';
|
||||
import { ThemedText } from '@/components/themed/ThemedText';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
import { RobustPressable } from '@/components/ui/RobustPressable';
|
||||
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
|
||||
import { useApp } from '@/context/AppContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
|
||||
/**
|
||||
* Folder view screen - displays items within a specific folder.
|
||||
* Simplified view with search scoped to this folder only.
|
||||
*/
|
||||
export default function FolderViewScreen(): React.ReactNode {
|
||||
const { id: folderId } = useLocalSearchParams<{ id: string }>();
|
||||
const { syncVault } = useVaultSync();
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const flatListRef = useRef<FlatList<Item | null>>(null);
|
||||
|
||||
const [itemsList, setItemsList] = useState<Item[]>([]);
|
||||
const [folder, setFolder] = useState<Folder | null>(null);
|
||||
const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200);
|
||||
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
|
||||
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
// Search state (scoped to this folder)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Folder modals
|
||||
const [showEditFolderModal, setShowEditFolderModal] = useState(false);
|
||||
const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false);
|
||||
|
||||
const authContext = useApp();
|
||||
const dbContext = useDb();
|
||||
|
||||
const isAuthenticated = authContext.isLoggedIn;
|
||||
const isDatabaseAvailable = dbContext.dbAvailable;
|
||||
|
||||
/**
|
||||
* Filter items by search query (within this folder only).
|
||||
*/
|
||||
const filteredItems = useMemo(() => {
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
|
||||
if (!searchLower) {
|
||||
return itemsList;
|
||||
}
|
||||
|
||||
return itemsList.filter(item => {
|
||||
const searchableFields = [
|
||||
item.Name?.toLowerCase() || '',
|
||||
getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '',
|
||||
getFieldValue(item, FieldKey.LoginEmail)?.toLowerCase() || '',
|
||||
getFieldValue(item, FieldKey.LoginUrl)?.toLowerCase() || '',
|
||||
getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '',
|
||||
];
|
||||
|
||||
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
return searchWords.every(word =>
|
||||
searchableFields.some(field => field.includes(word))
|
||||
);
|
||||
});
|
||||
}, [itemsList, searchQuery]);
|
||||
|
||||
/**
|
||||
* Load items in this folder and folder details.
|
||||
*/
|
||||
const loadItems = useCallback(async (): Promise<void> => {
|
||||
if (!folderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [items, folders] = await Promise.all([
|
||||
dbContext.sqliteClient!.getAllItems(),
|
||||
dbContext.sqliteClient!.getAllFolders()
|
||||
]);
|
||||
// Filter to only items in this folder
|
||||
const folderItems = items.filter((item: Item) => item.FolderId === folderId);
|
||||
setItemsList(folderItems);
|
||||
|
||||
// Find this folder
|
||||
const currentFolder = folders.find((f: Folder) => f.Id === folderId);
|
||||
setFolder(currentFolder || null);
|
||||
setIsLoadingItems(false);
|
||||
} catch (err) {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: t('items.errorLoadingItems'),
|
||||
text2: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setIsLoadingItems(false);
|
||||
}
|
||||
}, [dbContext.sqliteClient, folderId, setIsLoadingItems, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Add listener for item changes
|
||||
const itemChangedSub = emitter.addListener('credentialChanged', async () => {
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
itemChangedSub.remove();
|
||||
};
|
||||
}, [loadItems]);
|
||||
|
||||
/**
|
||||
* Handle pull-to-refresh.
|
||||
*/
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (Platform.OS === 'ios' || Platform.OS === 'android') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
|
||||
setRefreshing(true);
|
||||
setIsLoadingItems(true);
|
||||
|
||||
if (authContext.isOffline) {
|
||||
setRefreshing(false);
|
||||
setIsLoadingItems(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await syncVault({
|
||||
/**
|
||||
* On success.
|
||||
*/
|
||||
onSuccess: async (hasNewVault) => {
|
||||
await loadItems();
|
||||
setIsLoadingItems(false);
|
||||
setRefreshing(false);
|
||||
setTimeout(() => {
|
||||
Toast.show({
|
||||
type: 'success',
|
||||
text1: hasNewVault ? t('items.vaultSyncedSuccessfully') : t('items.vaultUpToDate'),
|
||||
position: 'top',
|
||||
visibilityTime: 1200,
|
||||
});
|
||||
}, 200);
|
||||
},
|
||||
/**
|
||||
* On offline.
|
||||
*/
|
||||
onOffline: () => {
|
||||
setRefreshing(false);
|
||||
setIsLoadingItems(false);
|
||||
authContext.setOfflineMode(true);
|
||||
setTimeout(() => {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: t('items.offlineMessage'),
|
||||
position: 'bottom',
|
||||
});
|
||||
}, 200);
|
||||
},
|
||||
/**
|
||||
* On error.
|
||||
*/
|
||||
onError: async (error) => {
|
||||
console.error('Error syncing vault:', error);
|
||||
setRefreshing(false);
|
||||
setIsLoadingItems(false);
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
error,
|
||||
[{ text: t('common.ok'), style: 'default' }]
|
||||
);
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: (): void => {
|
||||
router.replace('/upgrade');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error refreshing items:', err);
|
||||
setRefreshing(false);
|
||||
setIsLoadingItems(false);
|
||||
|
||||
if (!(err instanceof VaultAuthenticationError)) {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: t('items.vaultSyncFailed'),
|
||||
text2: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [syncVault, loadItems, setIsLoadingItems, setRefreshing, authContext, router, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isDatabaseAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingItems(true);
|
||||
loadItems();
|
||||
}, [isAuthenticated, isDatabaseAvailable, loadItems, setIsLoadingItems]);
|
||||
|
||||
/**
|
||||
* Set up header with folder name and edit/delete buttons.
|
||||
*/
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: folder?.Name || t('items.folders.folder'),
|
||||
/**
|
||||
* Header right buttons for edit and delete.
|
||||
*/
|
||||
headerRight: (): React.ReactNode => (
|
||||
<View style={{ flexDirection: 'row', gap: 4 }}>
|
||||
<RobustPressable
|
||||
onPress={() => setShowEditFolderModal(true)}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="edit"
|
||||
size={Platform.OS === 'android' ? 24 : 22}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</RobustPressable>
|
||||
<RobustPressable
|
||||
onPress={() => setShowDeleteFolderModal(true)}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={Platform.OS === 'android' ? 24 : 22}
|
||||
color={colors.destructive}
|
||||
/>
|
||||
</RobustPressable>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [navigation, folder?.Name, colors.primary, colors.destructive, t]);
|
||||
|
||||
/**
|
||||
* Delete an item (move to trash).
|
||||
*/
|
||||
const onItemDelete = useCallback(async (itemId: string): Promise<void> => {
|
||||
setIsSyncing(true);
|
||||
|
||||
await executeVaultMutation(async () => {
|
||||
await dbContext.sqliteClient!.trashItem(itemId);
|
||||
setIsSyncing(false);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
await loadItems();
|
||||
}, [dbContext.sqliteClient, executeVaultMutation, loadItems]);
|
||||
|
||||
/**
|
||||
* Rename the folder.
|
||||
*/
|
||||
const handleEditFolder = useCallback(async (newName: string) => {
|
||||
if (!folderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await executeVaultMutation(async () => {
|
||||
await dbContext.sqliteClient!.updateFolder(folderId, newName);
|
||||
});
|
||||
await loadItems();
|
||||
setShowEditFolderModal(false);
|
||||
}, [dbContext.sqliteClient, folderId, executeVaultMutation, loadItems]);
|
||||
|
||||
/**
|
||||
* Delete the folder (keep items - move them to root).
|
||||
*/
|
||||
const handleDeleteFolderOnly = useCallback(async () => {
|
||||
if (!folderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await executeVaultMutation(async () => {
|
||||
await dbContext.sqliteClient!.deleteFolder(folderId);
|
||||
});
|
||||
router.back();
|
||||
}, [dbContext.sqliteClient, folderId, executeVaultMutation, router]);
|
||||
|
||||
/**
|
||||
* Delete the folder and all its contents.
|
||||
*/
|
||||
const handleDeleteFolderAndContents = useCallback(async () => {
|
||||
if (!folderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await executeVaultMutation(async () => {
|
||||
await dbContext.sqliteClient!.deleteFolderWithContents(folderId);
|
||||
});
|
||||
router.back();
|
||||
}, [dbContext.sqliteClient, folderId, executeVaultMutation, router]);
|
||||
|
||||
/**
|
||||
* Handle FAB press - navigate to add item screen with folder pre-selected.
|
||||
*/
|
||||
const handleAddItem = useCallback(() => {
|
||||
router.push(`/(tabs)/items/add-edit?folderId=${folderId}` as '/(tabs)/items/add-edit');
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}, [folderId, router]);
|
||||
|
||||
// Header styles (stable, not dependent on colors) - prefixed with _ as styles are inlined in useEffect
|
||||
const _headerStyles = StyleSheet.create({
|
||||
headerButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerRightContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const paddingTop = Platform.OS === 'ios' ? 16 : 16;
|
||||
const paddingBottom = Platform.OS === 'ios' ? insets.bottom + 60 : 40;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: paddingTop + 100,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: paddingBottom,
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: paddingTop,
|
||||
},
|
||||
// Search styles
|
||||
searchContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
searchIcon: {
|
||||
left: 12,
|
||||
position: 'absolute',
|
||||
top: 11,
|
||||
zIndex: 1,
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 8,
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
height: 40,
|
||||
lineHeight: 20,
|
||||
marginBottom: 16,
|
||||
paddingLeft: 40,
|
||||
paddingRight: Platform.OS === 'android' ? 40 : 12,
|
||||
},
|
||||
clearButton: {
|
||||
padding: 4,
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 4,
|
||||
},
|
||||
clearButtonText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 20,
|
||||
},
|
||||
// Item count styles
|
||||
itemCountContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
itemCountText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
// Empty state styles
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginTop: 24,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyHint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
opacity: 0.6,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
// FAB styles
|
||||
fab: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 28,
|
||||
bottom: Platform.OS === 'ios' ? insets.bottom + 60 : 16,
|
||||
elevation: 4,
|
||||
height: 56,
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
shadowColor: colors.black,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
width: 56,
|
||||
zIndex: 1000,
|
||||
},
|
||||
fabIcon: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 24,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Render the list header with search.
|
||||
*/
|
||||
const renderListHeader = (): React.ReactNode => {
|
||||
return (
|
||||
<ThemedView>
|
||||
{/* Search input */}
|
||||
<ThemedView style={styles.searchContainer}>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={20}
|
||||
color={colors.textMuted}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={t('items.searchPlaceholder')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
multiline={false}
|
||||
numberOfLines={1}
|
||||
onChangeText={setSearchQuery}
|
||||
clearButtonMode={Platform.OS === 'ios' ? 'while-editing' : 'never'}
|
||||
/>
|
||||
{Platform.OS === 'android' && searchQuery.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearButton}
|
||||
onPress={() => setSearchQuery('')}
|
||||
>
|
||||
<ThemedText style={styles.clearButtonText}>×</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render empty state.
|
||||
*/
|
||||
const renderEmptyComponent = (): React.ReactNode => {
|
||||
if (isLoadingItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery
|
||||
? t('items.noMatchingItems')
|
||||
: t('items.folders.emptyFolder')
|
||||
}
|
||||
</Text>
|
||||
{!searchQuery && (
|
||||
<Text style={styles.emptyHint}>
|
||||
{t('items.folders.emptyFolderHint')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedContainer style={styles.container}>
|
||||
{isSyncing && <LoadingOverlay status={syncStatus} />}
|
||||
|
||||
{/* FAB */}
|
||||
<RobustPressable style={styles.fab} onPress={handleAddItem}>
|
||||
<MaterialIcons name="add" style={styles.fabIcon} />
|
||||
</RobustPressable>
|
||||
|
||||
{/* Item list */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={isLoadingItems ? Array(4).fill(null) : filteredItems}
|
||||
keyExtractor={(itm, index) => itm?.Id ?? `skeleton-${index}`}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
initialNumToRender={14}
|
||||
maxToRenderPerBatch={14}
|
||||
windowSize={7}
|
||||
removeClippedSubviews={false}
|
||||
ListHeaderComponent={renderListHeader() as React.ReactElement}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary]}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item: itm }) =>
|
||||
isLoadingItems ? (
|
||||
<SkeletonLoader count={1} height={60} parts={2} />
|
||||
) : (
|
||||
<ItemCard item={itm} onItemDelete={onItemDelete} />
|
||||
)
|
||||
}
|
||||
ListEmptyComponent={renderEmptyComponent() as React.ReactElement}
|
||||
/>
|
||||
|
||||
{isLoading && <LoadingOverlay status={syncStatus || t('items.deletingItem')} />}
|
||||
|
||||
{/* Folder modals */}
|
||||
<FolderModal
|
||||
isOpen={showEditFolderModal}
|
||||
onClose={() => setShowEditFolderModal(false)}
|
||||
onSave={handleEditFolder}
|
||||
initialName={folder?.Name || ''}
|
||||
mode="edit"
|
||||
/>
|
||||
<DeleteFolderModal
|
||||
isOpen={showDeleteFolderModal}
|
||||
onClose={() => setShowDeleteFolderModal(false)}
|
||||
onDeleteFolderOnly={handleDeleteFolderOnly}
|
||||
onDeleteFolderAndContents={handleDeleteFolderAndContents}
|
||||
itemCount={itemsList.length}
|
||||
/>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
220
apps/mobile-app/components/folders/DeleteFolderModal.tsx
Normal file
220
apps/mobile-app/components/folders/DeleteFolderModal.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
interface IDeleteFolderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDeleteFolderOnly: () => Promise<void>;
|
||||
onDeleteFolderAndContents: () => Promise<void>;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for deleting a folder with options to keep or delete contents.
|
||||
*/
|
||||
export const DeleteFolderModal: React.FC<IDeleteFolderModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeleteFolderOnly,
|
||||
onDeleteFolderAndContents,
|
||||
itemCount,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Handle delete folder only (move items to root).
|
||||
*/
|
||||
const handleDeleteFolderOnly = async (): Promise<void> => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onDeleteFolderOnly();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Error deleting folder:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle delete folder and all contents.
|
||||
*/
|
||||
const handleDeleteFolderAndContents = async (): Promise<void> => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onDeleteFolderAndContents();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Error deleting folder with contents:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle close - only allow if not submitting.
|
||||
*/
|
||||
const handleClose = (): void => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginTop: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 20,
|
||||
maxWidth: 400,
|
||||
padding: 20,
|
||||
width: '90%',
|
||||
},
|
||||
optionButton: {
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
padding: 14,
|
||||
},
|
||||
optionButtonDanger: {
|
||||
borderColor: colors.destructive,
|
||||
},
|
||||
optionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
optionDescription: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
optionDescriptionDanger: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
optionIcon: {
|
||||
marginTop: 2,
|
||||
},
|
||||
optionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
optionTitleDanger: {
|
||||
color: colors.destructive,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t('items.folders.deleteFolder')}</Text>
|
||||
|
||||
{/* Option 1: Delete folder only - move items to root */}
|
||||
<TouchableOpacity
|
||||
style={styles.optionButton}
|
||||
onPress={handleDeleteFolderOnly}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="folder"
|
||||
size={22}
|
||||
color={colors.tint}
|
||||
style={styles.optionIcon}
|
||||
/>
|
||||
<View style={styles.optionContent}>
|
||||
<Text style={styles.optionTitle}>
|
||||
{t('items.folders.deleteFolderKeepItems')}
|
||||
</Text>
|
||||
<Text style={styles.optionDescription}>
|
||||
{t('items.folders.deleteFolderKeepItemsDescription')}
|
||||
</Text>
|
||||
</View>
|
||||
{isSubmitting && <ActivityIndicator size="small" color={colors.primary} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Option 2: Delete folder and contents */}
|
||||
{itemCount > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.optionButton, styles.optionButtonDanger]}
|
||||
onPress={handleDeleteFolderAndContents}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={22}
|
||||
color={colors.destructive}
|
||||
style={styles.optionIcon}
|
||||
/>
|
||||
<View style={styles.optionContent}>
|
||||
<Text style={[styles.optionTitle, styles.optionTitleDanger]}>
|
||||
{t('items.folders.deleteFolderAndItems')}
|
||||
</Text>
|
||||
<Text style={[styles.optionDescription, styles.optionDescriptionDanger]}>
|
||||
{t('items.folders.deleteFolderAndItemsDescription', { count: itemCount })}
|
||||
</Text>
|
||||
</View>
|
||||
{isSubmitting && <ActivityIndicator size="small" color={colors.destructive} />}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>{t('common.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteFolderModal;
|
||||
223
apps/mobile-app/components/folders/FolderModal.tsx
Normal file
223
apps/mobile-app/components/folders/FolderModal.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableWithoutFeedback,
|
||||
Keyboard,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
interface IFolderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (folderName: string) => Promise<void>;
|
||||
initialName?: string;
|
||||
mode: 'create' | 'edit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for creating or editing a folder.
|
||||
*/
|
||||
export const FolderModal: React.FC<IFolderModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialName = '',
|
||||
mode,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const [folderName, setFolderName] = useState(initialName);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFolderName(initialName);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, initialName]);
|
||||
|
||||
/**
|
||||
* Handle the form submission.
|
||||
*/
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
const trimmedName = folderName.trim();
|
||||
if (!trimmedName) {
|
||||
setError(t('items.folders.folderNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSave(trimmedName);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(t('common.errors.unknownErrorTryAgain'));
|
||||
console.error('Error saving folder:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle close - only allow if not submitting
|
||||
*/
|
||||
const handleClose = (): void => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 20,
|
||||
},
|
||||
cancelButton: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 20,
|
||||
maxWidth: 400,
|
||||
padding: 20,
|
||||
width: '90%',
|
||||
},
|
||||
errorText: {
|
||||
color: colors.destructive,
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
label: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
saveButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.tint,
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.backdrop}
|
||||
>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>
|
||||
{mode === 'create' ? t('items.folders.createFolder') : t('items.folders.editFolder')}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.label}>{t('items.folders.folderName')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={folderName}
|
||||
onChangeText={setFolderName}
|
||||
placeholder={t('items.folders.folderNamePlaceholder')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
autoCapitalize="sentences"
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>{t('common.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isSubmitting && styles.saveButtonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color="#FFFFFF" size="small" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>
|
||||
{mode === 'create' ? t('common.add') : t('common.save')}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderModal;
|
||||
67
apps/mobile-app/components/folders/FolderPill.tsx
Normal file
67
apps/mobile-app/components/folders/FolderPill.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
/**
|
||||
* Folder with item count for display.
|
||||
*/
|
||||
export type FolderWithCount = {
|
||||
id: string;
|
||||
name: string;
|
||||
itemCount: number;
|
||||
};
|
||||
|
||||
interface IFolderPillProps {
|
||||
folder: FolderWithCount;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FolderPill component
|
||||
*
|
||||
* Displays a folder as a compact pill/tag that can be clicked to navigate into.
|
||||
* Designed to be displayed inline with other folder pills.
|
||||
*/
|
||||
export const FolderPill: React.FC<IFolderPillProps> = ({ folder, onPress }) => {
|
||||
const colors = useColors();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
folderName: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
maxWidth: 120,
|
||||
},
|
||||
itemCount: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.container} onPress={onPress} activeOpacity={0.7}>
|
||||
<MaterialIcons name="folder" size={16} color={colors.tint} />
|
||||
<Text style={styles.folderName} numberOfLines={1} ellipsizeMode="tail">
|
||||
{folder.name}
|
||||
</Text>
|
||||
{folder.itemCount > 0 && (
|
||||
<Text style={styles.itemCount}>{folder.itemCount}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderPill;
|
||||
233
apps/mobile-app/components/folders/FolderSelector.tsx
Normal file
233
apps/mobile-app/components/folders/FolderSelector.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type Folder = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
};
|
||||
|
||||
interface IFolderSelectorProps {
|
||||
folders: Folder[];
|
||||
selectedFolderId: string | null | undefined;
|
||||
onFolderChange: (folderId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FolderSelector component
|
||||
*
|
||||
* A button that opens a modal to select a folder for an item.
|
||||
* Can be placed anywhere in the form.
|
||||
*/
|
||||
export const FolderSelector: React.FC<IFolderSelectorProps> = ({
|
||||
folders,
|
||||
selectedFolderId,
|
||||
onFolderChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const selectedFolder = folders.find(f => f.Id === selectedFolderId);
|
||||
|
||||
/**
|
||||
* Handle folder selection.
|
||||
*/
|
||||
const handleSelectFolder = useCallback((folderId: string | null): void => {
|
||||
onFolderChange(folderId);
|
||||
setShowModal(false);
|
||||
}, [onFolderChange]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: selectedFolderId ? colors.tint + '20' : colors.accentBackground,
|
||||
borderColor: selectedFolderId ? colors.tint : colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: selectedFolderId ? colors.tint : colors.textMuted,
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 20,
|
||||
maxHeight: '70%',
|
||||
maxWidth: 400,
|
||||
padding: 20,
|
||||
width: '90%',
|
||||
},
|
||||
folderOption: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
folderOptionActive: {
|
||||
backgroundColor: colors.tint + '15',
|
||||
},
|
||||
folderOptionText: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
},
|
||||
folderOptionTextActive: {
|
||||
color: colors.tint,
|
||||
fontWeight: '600',
|
||||
},
|
||||
label: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 6,
|
||||
},
|
||||
optionsList: {
|
||||
marginTop: 16,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
wrapper: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Text style={styles.label}>{t('items.folders.folder')}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => setShowModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="folder"
|
||||
size={20}
|
||||
color={selectedFolderId ? colors.tint : colors.textMuted}
|
||||
/>
|
||||
<Text style={styles.buttonText} numberOfLines={1}>
|
||||
{selectedFolder ? selectedFolder.Name : t('items.folders.noFolder')}
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name="keyboard-arrow-down"
|
||||
size={20}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={showModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowModal(false)}
|
||||
>
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t('items.folders.selectFolder')}</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={() => setShowModal(false)}
|
||||
>
|
||||
<MaterialIcons name="close" size={24} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<ScrollView style={styles.optionsList}>
|
||||
{/* No folder option */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.folderOption,
|
||||
!selectedFolderId && styles.folderOptionActive,
|
||||
]}
|
||||
onPress={() => handleSelectFolder(null)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="folder-open"
|
||||
size={22}
|
||||
color={!selectedFolderId ? colors.tint : colors.textMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.folderOptionText,
|
||||
!selectedFolderId && styles.folderOptionTextActive,
|
||||
]}
|
||||
>
|
||||
{t('items.folders.noFolder')}
|
||||
</Text>
|
||||
{!selectedFolderId && (
|
||||
<MaterialIcons name="check" size={20} color={colors.tint} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Folder options */}
|
||||
{folders.map(folder => (
|
||||
<TouchableOpacity
|
||||
key={folder.Id}
|
||||
style={[
|
||||
styles.folderOption,
|
||||
selectedFolderId === folder.Id && styles.folderOptionActive,
|
||||
]}
|
||||
onPress={() => handleSelectFolder(folder.Id)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="folder"
|
||||
size={22}
|
||||
color={selectedFolderId === folder.Id ? colors.tint : colors.textMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.folderOptionText,
|
||||
selectedFolderId === folder.Id && styles.folderOptionTextActive,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{folder.Name}
|
||||
</Text>
|
||||
{selectedFolderId === folder.Id && (
|
||||
<MaterialIcons name="check" size={20} color={colors.tint} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderSelector;
|
||||
@@ -533,6 +533,7 @@
|
||||
"noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No items with attachments found",
|
||||
"noItemsOfTypeFound": "No {{type}} items found",
|
||||
"recentEmails": "Recent emails",
|
||||
"loadingEmails": "Loading emails...",
|
||||
"noEmailsYet": "No emails received yet.",
|
||||
@@ -595,7 +596,24 @@
|
||||
"copyEmail": "Copy Email",
|
||||
"copyPassword": "Copy Password"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete this item? This action cannot be undone."
|
||||
"deleteConfirm": "Are you sure you want to delete this item? This action cannot be undone.",
|
||||
"folders": {
|
||||
"folder": "Folder",
|
||||
"newFolder": "New Folder",
|
||||
"createFolder": "Create Folder",
|
||||
"editFolder": "Edit Folder",
|
||||
"folderName": "Folder Name",
|
||||
"folderNamePlaceholder": "e.g., Work, Personal",
|
||||
"folderNameRequired": "Folder name is required",
|
||||
"deleteFolder": "Delete Folder",
|
||||
"deleteFolderKeepItems": "Delete folder only",
|
||||
"deleteFolderKeepItemsDescription": "Items will be moved back to the main list.",
|
||||
"deleteFolderAndItems": "Delete folder and all items",
|
||||
"deleteFolderAndItemsDescription": "{{count}} item(s) will be moved to Recently Deleted.",
|
||||
"emptyFolderHint": "To move items to this folder, edit the item and select this folder.",
|
||||
"noFolder": "No Folder",
|
||||
"selectFolder": "Select Folder"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
"title": "Emails",
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as dateFormatter from '@/utils/dateFormatter';
|
||||
import { ItemRepository } from '@/utils/db/repositories/ItemRepository';
|
||||
import { SettingsRepository } from '@/utils/db/repositories/SettingsRepository';
|
||||
import { LogoRepository } from '@/utils/db/repositories/LogoRepository';
|
||||
import { FolderRepository, type Folder } from '@/utils/db/repositories/FolderRepository';
|
||||
import type { IDatabaseClient, SqliteBindValue } from '@/utils/db/BaseRepository';
|
||||
import type { ItemWithDeletedAt } from '@/utils/db/mappers/ItemMapper';
|
||||
|
||||
@@ -24,6 +25,7 @@ class SqliteClient implements IDatabaseClient {
|
||||
private _itemRepository: ItemRepository | null = null;
|
||||
private _settingsRepository: SettingsRepository | null = null;
|
||||
private _logoRepository: LogoRepository | null = null;
|
||||
private _folderRepository: FolderRepository | null = null;
|
||||
|
||||
/**
|
||||
* Get the ItemRepository instance (lazy initialization).
|
||||
@@ -84,6 +86,25 @@ class SqliteClient implements IDatabaseClient {
|
||||
return this._logoRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the FolderRepository instance (lazy initialization).
|
||||
*/
|
||||
public get folderRepository(): FolderRepository {
|
||||
if (!this._folderRepository) {
|
||||
this._folderRepository = Object.setPrototypeOf(
|
||||
{ client: this as IDatabaseClient },
|
||||
FolderRepository.prototype
|
||||
) as FolderRepository;
|
||||
Object.getOwnPropertyNames(FolderRepository.prototype).forEach(name => {
|
||||
const method = FolderRepository.prototype[name as keyof typeof FolderRepository.prototype];
|
||||
if (typeof method === 'function' && name !== 'constructor') {
|
||||
(this._folderRepository as unknown as Record<string, unknown>)[name] = method.bind(this._folderRepository);
|
||||
}
|
||||
});
|
||||
}
|
||||
return this._folderRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the encrypted database via the native code implementation.
|
||||
*/
|
||||
@@ -404,6 +425,75 @@ class SqliteClient implements IDatabaseClient {
|
||||
return this.settingsRepository.getAttachmentsForItem(itemId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NEW: Folder-based methods using repository pattern
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all folders.
|
||||
* @returns Array of Folder objects
|
||||
*/
|
||||
public async getAllFolders(): Promise<Folder[]> {
|
||||
return this.folderRepository.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a folder by ID.
|
||||
* @param folderId - The ID of the folder
|
||||
* @returns Folder object or null if not found
|
||||
*/
|
||||
public async getFolderById(folderId: string): Promise<Omit<Folder, 'Weight'> | null> {
|
||||
return this.folderRepository.getById(folderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new folder.
|
||||
* @param name - The name of the folder
|
||||
* @param parentFolderId - Optional parent folder ID for nested folders
|
||||
* @returns The ID of the created folder
|
||||
*/
|
||||
public async createFolder(name: string, parentFolderId?: string | null): Promise<string> {
|
||||
return this.folderRepository.create(name, parentFolderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a folder's name.
|
||||
* @param folderId - The ID of the folder to update
|
||||
* @param name - The new name for the folder
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async updateFolder(folderId: string, name: string): Promise<number> {
|
||||
return this.folderRepository.update(folderId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder (soft delete). Items in the folder will be moved to root.
|
||||
* @param folderId - The ID of the folder to delete
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async deleteFolder(folderId: string): Promise<number> {
|
||||
return this.folderRepository.delete(folderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder and all its contents. Items will be moved to trash.
|
||||
* @param folderId - The ID of the folder to delete
|
||||
* @returns The number of items trashed
|
||||
*/
|
||||
public async deleteFolderWithContents(folderId: string): Promise<number> {
|
||||
return this.folderRepository.deleteWithContents(folderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item to a folder.
|
||||
* @param itemId - The ID of the item to move
|
||||
* @param folderId - The ID of the destination folder (null to remove from folder)
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async moveItemToFolder(itemId: string, folderId: string | null): Promise<number> {
|
||||
return this.folderRepository.moveItem(itemId, folderId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LEGACY: Credential-based methods (kept for backward compatibility)
|
||||
// ============================================================================
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export class ItemQueries {
|
||||
/**
|
||||
* Base SELECT for items with common fields.
|
||||
* Includes LEFT JOIN to Logos, and subqueries for HasPasskey/HasAttachment/HasTotp.
|
||||
* Includes LEFT JOIN to Logos and Folders, and subqueries for HasPasskey/HasAttachment/HasTotp.
|
||||
*/
|
||||
public static readonly BASE_SELECT = `
|
||||
SELECT DISTINCT
|
||||
@@ -14,6 +14,7 @@ export class ItemQueries {
|
||||
i.Name,
|
||||
i.ItemType,
|
||||
i.FolderId,
|
||||
f.Name as FolderPath,
|
||||
l.FileData as Logo,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
|
||||
@@ -21,7 +22,8 @@ export class ItemQueries {
|
||||
i.CreatedAt,
|
||||
i.UpdatedAt
|
||||
FROM Items i
|
||||
LEFT JOIN Logos l ON i.LogoId = l.Id`;
|
||||
LEFT JOIN Logos l ON i.LogoId = l.Id
|
||||
LEFT JOIN Folders f ON i.FolderId = f.Id`;
|
||||
|
||||
/**
|
||||
* Get all active items (not deleted, not in trash).
|
||||
@@ -40,6 +42,7 @@ export class ItemQueries {
|
||||
i.Name,
|
||||
i.ItemType,
|
||||
i.FolderId,
|
||||
f.Name as FolderPath,
|
||||
l.FileData as Logo,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
|
||||
@@ -48,6 +51,7 @@ export class ItemQueries {
|
||||
i.UpdatedAt
|
||||
FROM Items i
|
||||
LEFT JOIN Logos l ON i.LogoId = l.Id
|
||||
LEFT JOIN Folders f ON i.FolderId = f.Id
|
||||
WHERE i.Id = ? AND i.IsDeleted = 0`;
|
||||
|
||||
/**
|
||||
|
||||
232
apps/mobile-app/utils/db/repositories/FolderRepository.ts
Normal file
232
apps/mobile-app/utils/db/repositories/FolderRepository.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { BaseRepository } from '../BaseRepository';
|
||||
|
||||
/**
|
||||
* Folder entity type.
|
||||
*/
|
||||
export type Folder = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
ParentFolderId: string | null;
|
||||
Weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL query constants for Folder operations.
|
||||
*/
|
||||
const FolderQueries = {
|
||||
/**
|
||||
* Get all active folders.
|
||||
*/
|
||||
GET_ALL: `
|
||||
SELECT Id, Name, ParentFolderId, Weight
|
||||
FROM Folders
|
||||
WHERE IsDeleted = 0
|
||||
ORDER BY Weight, Name`,
|
||||
|
||||
/**
|
||||
* Get folder by ID.
|
||||
*/
|
||||
GET_BY_ID: `
|
||||
SELECT Id, Name, ParentFolderId
|
||||
FROM Folders
|
||||
WHERE Id = ? AND IsDeleted = 0`,
|
||||
|
||||
/**
|
||||
* Insert a new folder.
|
||||
*/
|
||||
INSERT: `
|
||||
INSERT INTO Folders (Id, Name, ParentFolderId, Weight, IsDeleted, CreatedAt, UpdatedAt)
|
||||
VALUES (?, ?, ?, 0, 0, ?, ?)`,
|
||||
|
||||
/**
|
||||
* Update folder name.
|
||||
*/
|
||||
UPDATE_NAME: `
|
||||
UPDATE Folders
|
||||
SET Name = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`,
|
||||
|
||||
/**
|
||||
* Soft delete folder.
|
||||
*/
|
||||
SOFT_DELETE: `
|
||||
UPDATE Folders
|
||||
SET IsDeleted = 1,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`,
|
||||
|
||||
/**
|
||||
* Clear folder reference from items.
|
||||
*/
|
||||
CLEAR_ITEMS_FOLDER: `
|
||||
UPDATE Items
|
||||
SET FolderId = NULL,
|
||||
UpdatedAt = ?
|
||||
WHERE FolderId = ?`,
|
||||
|
||||
/**
|
||||
* Trash items in folder.
|
||||
*/
|
||||
TRASH_ITEMS_IN_FOLDER: `
|
||||
UPDATE Items
|
||||
SET DeletedAt = ?,
|
||||
UpdatedAt = ?,
|
||||
FolderId = NULL
|
||||
WHERE FolderId = ? AND IsDeleted = 0 AND DeletedAt IS NULL`,
|
||||
|
||||
/**
|
||||
* Move item to folder.
|
||||
*/
|
||||
MOVE_ITEM: `
|
||||
UPDATE Items
|
||||
SET FolderId = ?,
|
||||
UpdatedAt = ?
|
||||
WHERE Id = ?`
|
||||
};
|
||||
|
||||
/**
|
||||
* Repository for Folder CRUD operations.
|
||||
*/
|
||||
export class FolderRepository extends BaseRepository {
|
||||
/**
|
||||
* Create a new folder.
|
||||
* @param name - The name of the folder
|
||||
* @param parentFolderId - Optional parent folder ID for nested folders
|
||||
* @returns The ID of the created folder
|
||||
*/
|
||||
public async create(name: string, parentFolderId?: string | null): Promise<string> {
|
||||
return this.withTransaction(async () => {
|
||||
const folderId = this.generateId();
|
||||
const currentDateTime = this.now();
|
||||
|
||||
await this.client.executeUpdate(FolderQueries.INSERT, [
|
||||
folderId,
|
||||
name,
|
||||
parentFolderId || null,
|
||||
currentDateTime,
|
||||
currentDateTime
|
||||
]);
|
||||
|
||||
return folderId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all folders.
|
||||
* @returns Array of folder objects (empty array if Folders table doesn't exist yet)
|
||||
*/
|
||||
public async getAll(): Promise<Folder[]> {
|
||||
try {
|
||||
// Check if table exists first
|
||||
if (!await this.tableExists('Folders')) {
|
||||
return [];
|
||||
}
|
||||
return this.client.executeQuery<Folder>(FolderQueries.GET_ALL);
|
||||
} catch (error) {
|
||||
// Table may not exist in older vault versions - return empty array
|
||||
if (error instanceof Error && error.message.includes('no such table')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a folder by ID.
|
||||
* @param folderId - The ID of the folder
|
||||
* @returns Folder object or null if not found
|
||||
*/
|
||||
public async getById(folderId: string): Promise<Omit<Folder, 'Weight'> | null> {
|
||||
const results = await this.client.executeQuery<Omit<Folder, 'Weight'>>(
|
||||
FolderQueries.GET_BY_ID,
|
||||
[folderId]
|
||||
);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a folder's name.
|
||||
* @param folderId - The ID of the folder to update
|
||||
* @param name - The new name for the folder
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async update(folderId: string, name: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(FolderQueries.UPDATE_NAME, [
|
||||
name,
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder (soft delete).
|
||||
* Note: Items in the folder will have their FolderId set to NULL.
|
||||
* @param folderId - The ID of the folder to delete
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async delete(folderId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
|
||||
// Remove folder reference from all items in this folder
|
||||
await this.client.executeUpdate(FolderQueries.CLEAR_ITEMS_FOLDER, [
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
|
||||
// Soft delete the folder
|
||||
return this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder and all items within it (soft delete both folder and items).
|
||||
* Items are moved to "Recently Deleted" (trash).
|
||||
* @param folderId - The ID of the folder to delete
|
||||
* @returns The number of items trashed
|
||||
*/
|
||||
public async deleteWithContents(folderId: string): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
|
||||
// Move all items in this folder to trash and clear FolderId
|
||||
const itemsDeleted = await this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
|
||||
// Soft delete the folder
|
||||
await this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
|
||||
currentDateTime,
|
||||
folderId
|
||||
]);
|
||||
|
||||
return itemsDeleted;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item to a folder.
|
||||
* @param itemId - The ID of the item to move
|
||||
* @param folderId - The ID of the destination folder (null to remove from folder)
|
||||
* @returns The number of rows updated
|
||||
*/
|
||||
public async moveItem(itemId: string, folderId: string | null): Promise<number> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
return this.client.executeUpdate(FolderQueries.MOVE_ITEM, [
|
||||
folderId,
|
||||
currentDateTime,
|
||||
itemId
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user