From a6d3d7119c2bdd2d0fe111d19cf1a2701f52b637 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 28 Dec 2025 15:53:02 +0100 Subject: [PATCH] Add folder scaffolding (#1404) --- apps/mobile-app/app/(tabs)/items/_layout.tsx | 8 + apps/mobile-app/app/(tabs)/items/add-edit.tsx | 26 +- .../app/(tabs)/items/folder/[id].tsx | 562 +++++++++++ apps/mobile-app/app/(tabs)/items/index.tsx | 942 +++++++++++------- .../components/folders/DeleteFolderModal.tsx | 220 ++++ .../components/folders/FolderModal.tsx | 223 +++++ .../components/folders/FolderPill.tsx | 67 ++ .../components/folders/FolderSelector.tsx | 233 +++++ apps/mobile-app/i18n/locales/en.json | 20 +- apps/mobile-app/utils/SqliteClient.tsx | 90 ++ .../utils/db/queries/ItemQueries.ts | 8 +- .../utils/db/repositories/FolderRepository.ts | 232 +++++ 12 files changed, 2246 insertions(+), 385 deletions(-) create mode 100644 apps/mobile-app/app/(tabs)/items/folder/[id].tsx create mode 100644 apps/mobile-app/components/folders/DeleteFolderModal.tsx create mode 100644 apps/mobile-app/components/folders/FolderModal.tsx create mode 100644 apps/mobile-app/components/folders/FolderPill.tsx create mode 100644 apps/mobile-app/components/folders/FolderSelector.tsx create mode 100644 apps/mobile-app/utils/db/repositories/FolderRepository.ts diff --git a/apps/mobile-app/app/(tabs)/items/_layout.tsx b/apps/mobile-app/app/(tabs)/items/_layout.tsx index 7005a62f1..252c27baf 100644 --- a/apps/mobile-app/app/(tabs)/items/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/items/_layout.tsx @@ -21,6 +21,14 @@ export default function ItemsLayout(): React.ReactNode { ...defaultHeaderOptions, }} /> + ([]); + // Track manually added optional fields const [manuallyAddedFields, setManuallyAddedFields] = useState>(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 { )} ))} + {/* Folder selection */} + {folders.length > 0 && ( + { + setItem(prev => prev ? { ...prev, FolderId: folderId } : prev); + setHasUnsavedChanges(true); + }} + /> + )} {/* Passkey Section - only in edit mode for items with passkeys */} diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx new file mode 100644 index 000000000..58aa230e9 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -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>(null); + + const [itemsList, setItemsList] = useState([]); + const [folder, setFolder] = useState(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 => { + 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 => ( + + setShowEditFolderModal(true)} + style={{ padding: 8 }} + > + + + setShowDeleteFolderModal(true)} + style={{ padding: 8 }} + > + + + + ), + }); + }, [navigation, folder?.Name, colors.primary, colors.destructive, t]); + + /** + * Delete an item (move to trash). + */ + const onItemDelete = useCallback(async (itemId: string): Promise => { + 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 ( + + {/* Search input */} + + + + {Platform.OS === 'android' && searchQuery.length > 0 && ( + setSearchQuery('')} + > + × + + )} + + + ); + }; + + /** + * Render empty state. + */ + const renderEmptyComponent = (): React.ReactNode => { + if (isLoadingItems) { + return null; + } + + return ( + + + {searchQuery + ? t('items.noMatchingItems') + : t('items.folders.emptyFolder') + } + + {!searchQuery && ( + + {t('items.folders.emptyFolderHint')} + + )} + + ); + }; + + return ( + + {isSyncing && } + + {/* FAB */} + + + + + {/* Item list */} + 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={ + + } + renderItem={({ item: itm }) => + isLoadingItems ? ( + + ) : ( + + ) + } + ListEmptyComponent={renderEmptyComponent() as React.ReactElement} + /> + + {isLoading && } + + {/* Folder modals */} + setShowEditFolderModal(false)} + onSave={handleEditFolder} + initialName={folder?.Name || ''} + mode="edit" + /> + setShowDeleteFolderModal(false)} + onDeleteFolderOnly={handleDeleteFolderOnly} + onDeleteFolderAndContents={handleDeleteFolderAndContents} + itemCount={itemsList.length} + /> + + ); +} diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index 9302f4299..7586d9918 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -2,14 +2,15 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; import { useRouter, useLocalSearchParams } from 'expo-router'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, Text, FlatList, TouchableOpacity, TextInput, RefreshControl, Platform, Animated, Alert } from 'react-native'; +import { Alert, StyleSheet, Text, Platform, Animated, TextInput, TouchableOpacity, View, RefreshControl } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; -import type { Item } from '@/utils/dist/core/models/vault'; -import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; +import type { Folder } from '@/utils/db/repositories/FolderRepository'; +import type { Item, ItemType } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; @@ -18,11 +19,10 @@ import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; import { useVaultMutate } from '@/hooks/useVaultMutate'; import { useVaultSync } from '@/hooks/useVaultSync'; -type FilterType = 'all' | 'passkeys' | 'aliases' | 'userpass' | 'attachments'; - import Logo from '@/assets/images/logo.svg'; +import { FolderModal } from '@/components/folders/FolderModal'; +import { FolderPill, type FolderWithCount } from '@/components/folders/FolderPill'; import { ItemCard } from '@/components/items/ItemCard'; -import { ServiceUrlNotice } from '@/components/items/ServiceUrlNotice'; import LoadingOverlay from '@/components/LoadingOverlay'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedText } from '@/components/themed/ThemedText'; @@ -35,26 +35,61 @@ import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; /** - * Items screen. + * Filter types for the items list. */ -export default function ItemsScreen() : React.ReactNode { - const [searchQuery, setSearchQuery] = useState(''); +type FilterType = 'all' | 'passkeys' | 'attachments' | ItemType; + +/** + * Check if a filter is an item type filter. + */ +const isItemTypeFilter = (filter: FilterType): filter is ItemType => { + return Object.values(ItemTypes).includes(filter as ItemType); +}; + +/** + * Item type filter option configuration. + */ +type ItemTypeOption = { + type: ItemType; + titleKey: string; + iconName: keyof typeof MaterialIcons.glyphMap; +}; + +/** + * Available item type filter options with icons. + */ +const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [ + { type: ItemTypes.Login, titleKey: 'itemTypes.login.title', iconName: 'key' }, + { type: ItemTypes.Alias, titleKey: 'itemTypes.alias.title', iconName: 'person' }, + { type: ItemTypes.CreditCard, titleKey: 'itemTypes.creditCard.title', iconName: 'credit-card' }, + { type: ItemTypes.Note, titleKey: 'itemTypes.note.title', iconName: 'description' }, +]; + +/** + * Items screen - main vault items list. + */ +export default function ItemsScreen(): React.ReactNode { const { syncVault } = useVaultSync(); - const colors = useColors(); const { t } = useTranslation(); - const flatListRef = useRef(null); + const colors = useColors(); const scrollY = useRef(new Animated.Value(0)).current; const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const flatListRef = useRef>(null); const [isTabFocused, setIsTabFocused] = useState(false); const router = useRouter(); const { serviceUrl: serviceUrlParam } = useLocalSearchParams<{ serviceUrl?: string }>(); const [itemsList, setItemsList] = useState([]); + const [folders, setFolders] = useState([]); const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200); const [refreshing, setRefreshing] = useMinDurationLoading(false, 200); const [serviceUrl, setServiceUrl] = useState(null); - const insets = useSafeAreaInsets(); const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate(); const [isSyncing, setIsSyncing] = useState(false); + const [showFolderModal, setShowFolderModal] = useState(false); + + // Search and filter state + const [searchQuery, setSearchQuery] = useState(''); const [filterType, setFilterType] = useState('all'); const [showFilterMenu, setShowFilterMenu] = useState(false); @@ -65,15 +100,115 @@ export default function ItemsScreen() : React.ReactNode { const isDatabaseAvailable = dbContext.dbAvailable; /** - * Load items (credentials). + * Get folders with item counts for display. + */ + const foldersWithCounts = useMemo((): FolderWithCount[] => { + // Don't show folders when searching + if (searchQuery) { + return []; + } + + const folderCounts = new Map(); + + // Count items per folder + itemsList.forEach((item: Item) => { + if (item.FolderId) { + folderCounts.set(item.FolderId, (folderCounts.get(item.FolderId) || 0) + 1); + } + }); + + // Return folders with counts, sorted alphabetically + return folders.map(folder => ({ + id: folder.Id, + name: folder.Name, + itemCount: folderCounts.get(folder.Id) || 0 + })).sort((a, b) => a.name.localeCompare(b.name)); + }, [folders, itemsList, searchQuery]); + + /** + * Get the title based on the active filter. + */ + const getFilterTitle = useCallback((): string => { + switch (filterType) { + case 'passkeys': + return t('items.filters.passkeys'); + case 'attachments': + return t('common.attachments'); + case 'all': + return t('items.title'); + default: + if (isItemTypeFilter(filterType)) { + const itemTypeOption = ITEM_TYPE_OPTIONS.find(opt => opt.type === filterType); + if (itemTypeOption) { + return t(itemTypeOption.titleKey); + } + } + return t('items.title'); + } + }, [filterType, t]); + + /** + * Filter items by folder, type, and search query. + */ + const filteredItems = useMemo(() => { + return itemsList.filter(item => { + // Root view (no search): exclude items in folders + if (!searchQuery && item.FolderId) { + return false; + } + // When searching: show all matching items regardless of folder + + // Apply type filter + let passesTypeFilter = true; + + if (filterType === 'passkeys') { + passesTypeFilter = item.HasPasskey === true; + } else if (filterType === 'attachments') { + passesTypeFilter = item.HasAttachment === true; + } else if (isItemTypeFilter(filterType)) { + passesTypeFilter = item.ItemType === filterType; + } + + if (!passesTypeFilter) { + return false; + } + + // Apply search filter + const searchLower = searchQuery.toLowerCase().trim(); + + if (!searchLower) { + return true; + } + + 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, filterType]); + + /** + * Load items (credentials) and folders. */ const loadItems = useCallback(async (): Promise => { try { - const items = await dbContext.sqliteClient!.getAllItems(); + const [items, loadedFolders] = await Promise.all([ + dbContext.sqliteClient!.getAllItems(), + dbContext.sqliteClient!.getAllFolders() + ]); setItemsList(items); + setFolders(loadedFolders); setIsLoadingItems(false); } catch (err) { - // Error loading items, show error toast Toast.show({ type: 'error', text1: t('items.errorLoadingItems'), @@ -94,10 +229,7 @@ export default function ItemsScreen() : React.ReactNode { const tabPressSub = emitter.addListener('tabPress', (routeName: string) => { if (routeName === 'credentials' && isTabFocused) { - setSearchQuery(''); // Reset search - setRefreshing(false); // Reset refreshing - // Scroll to top - flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + setRefreshing(false); } }); @@ -114,33 +246,29 @@ export default function ItemsScreen() : React.ReactNode { }; }, [isTabFocused, loadItems, navigation, setRefreshing]); + /** + * Handle pull-to-refresh. + */ const onRefresh = useCallback(async () => { - // Trigger haptic feedback when pull-to-refresh is activated - if (Platform.OS === 'ios') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } else if (Platform.OS === 'android') { + if (Platform.OS === 'ios' || Platform.OS === 'android') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } setRefreshing(true); setIsLoadingItems(true); - // Check if we are in offline mode, if so, we don't need to refresh the credentials - const isOffline = authContext.isOffline; - if (isOffline) { + if (authContext.isOffline) { setRefreshing(false); setIsLoadingItems(false); return; } try { - // Sync vault and load credentials await syncVault({ /** * On success. */ onSuccess: async (hasNewVault) => { - // Calculate remaining time needed to reach minimum duration await loadItems(); setIsLoadingItems(false); setRefreshing(false); @@ -176,10 +304,6 @@ export default function ItemsScreen() : React.ReactNode { setRefreshing(false); setIsLoadingItems(false); - /** - * Authentication errors are handled in useVaultSync - * For other errors, show alert - */ Alert.alert( t('common.error'), error, @@ -189,7 +313,7 @@ export default function ItemsScreen() : React.ReactNode { /** * On upgrade required. */ - onUpgradeRequired: () : void => { + onUpgradeRequired: (): void => { router.replace('/upgrade'); }, }); @@ -198,7 +322,6 @@ export default function ItemsScreen() : React.ReactNode { setRefreshing(false); setIsLoadingItems(false); - // Authentication errors are already handled in useVaultSync if (!(err instanceof VaultAuthenticationError)) { Toast.show({ type: 'error', @@ -218,115 +341,85 @@ export default function ItemsScreen() : React.ReactNode { loadItems(); }, [isAuthenticated, isDatabaseAvailable, loadItems, setIsLoadingItems]); + // Set header for Android + useEffect(() => { + navigation.setOptions({ + /** + * Define custom header which is shown on Android. iOS displays the custom CollapsibleHeader component instead. + */ + headerTitle: (): React.ReactNode => { + if (Platform.OS === 'android') { + return ( + + ); + } + return {t('items.title')}; + }, + }); + }, [navigation, t]); + /** - * Get the title based on the active filter + * Delete an item (move to trash). */ - const getFilterTitle = useCallback(() : string => { - switch (filterType) { - case 'passkeys': - return t('items.filters.passkeys'); - case 'aliases': - return t('items.filters.aliases'); - case 'userpass': - return t('items.filters.userpass'); - case 'attachments': - return t('items.filters.attachments'); - default: - return t('items.title'); - } - }, [filterType, t]); + const onItemDelete = useCallback(async (itemId: string): Promise => { + setIsSyncing(true); - const filteredItems = itemsList.filter(item => { - // First apply type filter - let passesTypeFilter = true; + await executeVaultMutation(async () => { + await dbContext.sqliteClient!.trashItem(itemId); + setIsSyncing(false); + }); - if (filterType === 'passkeys') { - passesTypeFilter = item.HasPasskey === true; - } else if (filterType === 'aliases') { - // Check for non-empty alias fields (excluding email which is used everywhere) - const firstName = getFieldValue(item, FieldKey.AliasFirstName); - const lastName = getFieldValue(item, FieldKey.AliasLastName); - const gender = getFieldValue(item, FieldKey.AliasGender); - const birthDate = getFieldValue(item, FieldKey.AliasBirthdate); - passesTypeFilter = !!( - (firstName && firstName.trim()) || - (lastName && lastName.trim()) || - (gender && gender.trim()) || - (birthDate && birthDate.trim() && !birthDate.trim().startsWith('0001-01-01')) - ); - } else if (filterType === 'userpass') { - // Show only items that have username/password AND do NOT have alias fields AND do NOT have passkey - const firstName = getFieldValue(item, FieldKey.AliasFirstName); - const lastName = getFieldValue(item, FieldKey.AliasLastName); - const gender = getFieldValue(item, FieldKey.AliasGender); - const birthDate = getFieldValue(item, FieldKey.AliasBirthdate); - const hasAliasFields = !!( - (firstName && firstName.trim()) || - (lastName && lastName.trim()) || - (gender && gender.trim()) || - (birthDate && birthDate.trim() && !birthDate.trim().startsWith('0001-01-01')) - ); - const username = getFieldValue(item, FieldKey.LoginUsername); - const password = getFieldValue(item, FieldKey.LoginPassword); - const hasUsernameOrPassword = !!( - (username && username.trim()) || - (password && password.trim()) - ); - passesTypeFilter = hasUsernameOrPassword && !item.HasPasskey && !hasAliasFields; - } else if (filterType === 'attachments') { - passesTypeFilter = item.HasAttachment === true; - } + await new Promise(resolve => setTimeout(resolve, 250)); + await loadItems(); + }, [dbContext.sqliteClient, executeVaultMutation, loadItems]); - if (!passesTypeFilter) { - return false; - } + /** + * Navigate to a folder. + */ + const handleFolderClick = useCallback((folderId: string) => { + router.push(`/(tabs)/items/folder/${folderId}`); + }, [router]); - // Then apply search filter - const searchLower = searchQuery.toLowerCase().trim(); + /** + * Create a new folder. + */ + const handleCreateFolder = useCallback(async (folderName: string) => { + await executeVaultMutation(async () => { + await dbContext.sqliteClient!.createFolder(folderName, null); + }); + await loadItems(); + }, [dbContext.sqliteClient, executeVaultMutation, loadItems]); - if (!searchLower) { - return true; // No search term, include all - } + /** + * Handle FAB press - navigate to add item screen. + */ + const handleAddItem = useCallback(() => { + router.push('/(tabs)/items/add-edit'); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }, [router]); - /** - * We filter items by searching in the following fields: - * - Item name - * - Username - * - Email - * - URL - * - Notes - */ - 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() || '', - ]; - - // Split search term into words for AND search - const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0); - - // All search words must be found (each in at least one field) - return searchWords.every(word => - searchableFields.some(field => field.includes(word)) - ); - }); + // Handle deep link parameters + useFocusEffect( + useCallback(() => { + const currentServiceUrl = serviceUrlParam ? decodeURIComponent(serviceUrlParam) : null; + setServiceUrl(currentServiceUrl); + }, [serviceUrlParam]) + ); const styles = StyleSheet.create({ - clearButton: { - padding: 4, - position: 'absolute', - right: 8, - top: 4, - }, - clearButtonText: { - color: colors.textMuted, - fontSize: 20, - }, container: { paddingHorizontal: 0, }, + stepContainer: { + flex: 1, + gap: 8, + }, + contentContainer: { + paddingBottom: Platform.OS === 'ios' ? insets.bottom + 60 : 10, + paddingHorizontal: 14, + paddingTop: Platform.OS === 'ios' ? 42 : 8, + }, + // Filter button styles filterButton: { alignItems: 'center', flexDirection: 'row', @@ -344,6 +437,7 @@ export default function ItemsScreen() : React.ReactNode { fontSize: 20, lineHeight: 28, }, + // Filter menu styles filterMenu: { backgroundColor: colors.accentBackground, borderColor: colors.accentBorder, @@ -356,6 +450,14 @@ export default function ItemsScreen() : React.ReactNode { paddingHorizontal: 16, paddingVertical: 12, }, + filterMenuItemWithIcon: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + }, + filterMenuItemIcon: { + width: 18, + }, filterMenuItemActive: { backgroundColor: colors.primary + '20', }, @@ -367,11 +469,86 @@ export default function ItemsScreen() : React.ReactNode { color: colors.primary, fontWeight: '600', }, - contentContainer: { - paddingBottom: Platform.OS === 'ios' ? insets.bottom + 60 : 10, - paddingHorizontal: 14, - paddingTop: Platform.OS === 'ios' ? 42 : 16, + filterMenuSeparator: { + backgroundColor: colors.accentBorder, + height: 1, + marginVertical: 4, }, + // Folder pills styles + folderPillsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 12, + }, + newFolderButton: { + alignItems: 'center', + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 20, + borderStyle: 'dashed', + borderWidth: 1, + flexDirection: 'row', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + }, + newFolderButtonText: { + color: colors.textMuted, + fontSize: 14, + }, + // 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, + }, + // Service URL styles + serviceUrlContainer: { + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 12, + padding: 12, + }, + serviceUrlText: { + color: colors.text, + flex: 1, + fontSize: 14, + }, + serviceUrlDismiss: { + padding: 4, + }, + // Empty state styles emptyText: { color: colors.textMuted, fontSize: 16, @@ -379,6 +556,7 @@ export default function ItemsScreen() : React.ReactNode { opacity: 0.7, textAlign: 'center', }, + // FAB styles fab: { alignItems: 'center', backgroundColor: colors.primary, @@ -403,110 +581,262 @@ export default function ItemsScreen() : React.ReactNode { color: colors.primarySurfaceText, fontSize: 24, }, - 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, - }, - stepContainer: { - flex: 1, - gap: 8, - }, }); - // Set header buttons - useEffect(() => { - navigation.setOptions({ - /** - * Define custom header which is shown on Android. iOS displays the custom CollapsibleHeader component instead. - * @returns - */ - headerTitle: (): React.ReactNode => { - if (Platform.OS === 'android') { - return ( - { - setShowFilterMenu(!showFilterMenu); - }, - position: 'right' - } - ]} + /** + * Render the filter menu. + */ + const renderFilterMenu = (): React.ReactNode => { + if (!showFilterMenu) { + return null; + } + + return ( + + {/* All items filter */} + { + setFilterType('all'); + setShowFilterMenu(false); + }} + > + + {t('items.filters.all')} + + + + + + {/* Item type filters */} + {ITEM_TYPE_OPTIONS.map((option) => ( + { + setFilterType(option.type); + setShowFilterMenu(false); + }} + > + - ); - } - return {t('items.title')}; - }, - }); - }, [navigation, t, filterType, showFilterMenu, getFilterTitle, filteredItems.length]); + + {t(option.titleKey)} + + + ))} + + + + {/* Passkeys filter */} + { + setFilterType('passkeys'); + setShowFilterMenu(false); + }} + > + + {t('items.filters.passkeys')} + + + + {/* Attachments filter */} + { + setFilterType('attachments'); + setShowFilterMenu(false); + }} + > + + {t('common.attachments')} + + + + ); + }; /** - * Delete an item (move to trash). + * Render the list header with filter button, folders, and search. */ - const onItemDelete = useCallback(async (itemId: string): Promise => { - setIsSyncing(true); + const renderListHeader = (): React.ReactNode => { + return ( + + {/* Large header with logo (iOS only) */} + {Platform.OS === 'ios' && ( + setShowFilterMenu(!showFilterMenu)} + > + + + {getFilterTitle()} + + + ({filteredItems.length}) + + + + )} - await executeVaultMutation(async () => { - await dbContext.sqliteClient!.trashItem(itemId); - setIsSyncing(false); - }); + {/* Service URL notice */} + {serviceUrl && ( + + + + {serviceUrl} + + setServiceUrl(null)}> + + + + )} - // Refresh list after deletion with a small delay to ensure feedback is visible. - await new Promise(resolve => setTimeout(resolve, 250)); - await loadItems(); - }, [dbContext.sqliteClient, executeVaultMutation, loadItems]); + {/* Filter menu */} + {renderFilterMenu()} - // Handle deep link parameters - useFocusEffect( - useCallback(() => { - // Always check the current serviceUrlParam when screen comes into focus - const currentServiceUrl = serviceUrlParam ? decodeURIComponent(serviceUrlParam) : null; - setServiceUrl(currentServiceUrl); - }, [serviceUrlParam]) - ); + {/* Folder pills */} + {foldersWithCounts.length > 0 && ( + + {foldersWithCounts.map((folder) => ( + handleFolderClick(folder.id)} + /> + ))} + setShowFolderModal(true)} + > + + {t('items.folders.newFolder')} + + + )} + + {/* New folder button when no folders exist */} + {foldersWithCounts.length === 0 && !searchQuery && ( + + setShowFolderModal(true)} + > + + {t('items.folders.newFolder')} + + + )} + + {/* Search input */} + + + + {Platform.OS === 'android' && searchQuery.length > 0 && ( + setSearchQuery('')} + > + × + + )} + + + ); + }; + + /** + * Render empty state. + */ + const renderEmptyComponent = (): React.ReactNode => { + if (isLoadingItems) { + return null; + } + + return ( + + + {searchQuery + ? t('items.noMatchingItems') + : filterType === 'passkeys' + ? t('items.noPasskeysFound') + : filterType === 'attachments' + ? t('items.noAttachmentsFound') + : isItemTypeFilter(filterType) + ? t('items.noItemsOfTypeFound', { type: getFilterTitle() }) + : t('items.noItemsFound') + } + + + ); + }; return ( - {(isSyncing) && ( - - )} + {isSyncing && } - { - router.push('/(tabs)/items/add-edit'); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }} - > - - + {/* FAB */} + + + + + {/* Item list */} - {Platform.OS === 'ios' && ( - setShowFilterMenu(!showFilterMenu)} - > - - - {getFilterTitle()} - - - ({filteredItems.length}) - - - - )} - {serviceUrl && ( - setServiceUrl(null)} - /> - )} - {showFilterMenu && ( - - { - setFilterType('all'); - setShowFilterMenu(false); - }} - > - - {t('items.filters.all')} - - - { - setFilterType('passkeys'); - setShowFilterMenu(false); - }} - > - - {t('items.filters.passkeys')} - - - { - setFilterType('aliases'); - setShowFilterMenu(false); - }} - > - - {t('items.filters.aliases')} - - - { - setFilterType('userpass'); - setShowFilterMenu(false); - }} - > - - {t('items.filters.userpass')} - - - { - setFilterType('attachments'); - setShowFilterMenu(false); - }} - > - - {t('items.filters.attachments')} - - - - )} - - - - {Platform.OS === 'android' && searchQuery.length > 0 && ( - setSearchQuery('')} - > - × - - )} - - - } + ListHeaderComponent={renderListHeader() as React.ReactElement} refreshControl={ ) } - ListEmptyComponent={ - !isLoadingItems ? ( - - {searchQuery - ? t('items.noMatchingItems') - : filterType === 'passkeys' - ? t('items.noPasskeysFound') - : filterType === 'attachments' - ? t('items.noAttachmentsFound') - : t('items.noItemsFound') - } - - ) : null - } + ListEmptyComponent={renderEmptyComponent() as React.ReactElement} /> {isLoading && } + + {/* Create folder modal */} + setShowFolderModal(false)} + onSave={handleCreateFolder} + mode="create" + /> ); -} \ No newline at end of file +} diff --git a/apps/mobile-app/components/folders/DeleteFolderModal.tsx b/apps/mobile-app/components/folders/DeleteFolderModal.tsx new file mode 100644 index 000000000..5e5b01c4c --- /dev/null +++ b/apps/mobile-app/components/folders/DeleteFolderModal.tsx @@ -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; + onDeleteFolderAndContents: () => Promise; + itemCount: number; +} + +/** + * Modal for deleting a folder with options to keep or delete contents. + */ +export const DeleteFolderModal: React.FC = ({ + 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 => { + 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 => { + 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 ( + + + + {t('items.folders.deleteFolder')} + + {/* Option 1: Delete folder only - move items to root */} + + + + + {t('items.folders.deleteFolderKeepItems')} + + + {t('items.folders.deleteFolderKeepItemsDescription')} + + + {isSubmitting && } + + + {/* Option 2: Delete folder and contents */} + {itemCount > 0 && ( + + + + + {t('items.folders.deleteFolderAndItems')} + + + {t('items.folders.deleteFolderAndItemsDescription', { count: itemCount })} + + + {isSubmitting && } + + )} + + {/* Cancel button */} + + {t('common.cancel')} + + + + + ); +}; + +export default DeleteFolderModal; diff --git a/apps/mobile-app/components/folders/FolderModal.tsx b/apps/mobile-app/components/folders/FolderModal.tsx new file mode 100644 index 000000000..717c1d2fc --- /dev/null +++ b/apps/mobile-app/components/folders/FolderModal.tsx @@ -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; + initialName?: string; + mode: 'create' | 'edit'; +} + +/** + * Modal for creating or editing a folder. + */ +export const FolderModal: React.FC = ({ + 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(null); + + useEffect(() => { + if (isOpen) { + setFolderName(initialName); + setError(null); + } + }, [isOpen, initialName]); + + /** + * Handle the form submission. + */ + const handleSubmit = async (): Promise => { + 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 ( + + + + + + + {mode === 'create' ? t('items.folders.createFolder') : t('items.folders.editFolder')} + + + {t('items.folders.folderName')} + + + {error && {error}} + + + + {t('common.cancel')} + + + + {isSubmitting ? ( + + ) : ( + + {mode === 'create' ? t('common.add') : t('common.save')} + + )} + + + + + + + + ); +}; + +export default FolderModal; diff --git a/apps/mobile-app/components/folders/FolderPill.tsx b/apps/mobile-app/components/folders/FolderPill.tsx new file mode 100644 index 000000000..7246bc01e --- /dev/null +++ b/apps/mobile-app/components/folders/FolderPill.tsx @@ -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 = ({ 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 ( + + + + {folder.name} + + {folder.itemCount > 0 && ( + {folder.itemCount} + )} + + ); +}; + +export default FolderPill; diff --git a/apps/mobile-app/components/folders/FolderSelector.tsx b/apps/mobile-app/components/folders/FolderSelector.tsx new file mode 100644 index 000000000..392437f8b --- /dev/null +++ b/apps/mobile-app/components/folders/FolderSelector.tsx @@ -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 = ({ + 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 ( + + {t('items.folders.folder')} + setShowModal(true)} + activeOpacity={0.7} + > + + + {selectedFolder ? selectedFolder.Name : t('items.folders.noFolder')} + + + + + setShowModal(false)} + > + + + {t('items.folders.selectFolder')} + + setShowModal(false)} + > + + + + + {/* No folder option */} + handleSelectFolder(null)} + > + + + {t('items.folders.noFolder')} + + {!selectedFolderId && ( + + )} + + + {/* Folder options */} + {folders.map(folder => ( + handleSelectFolder(folder.Id)} + > + + + {folder.Name} + + {selectedFolderId === folder.Id && ( + + )} + + ))} + + + + + + ); +}; + +export default FolderSelector; diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index eb25489d9..1128c347c 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -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", diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx index a4bae0a3c..9f885bf92 100644 --- a/apps/mobile-app/utils/SqliteClient.tsx +++ b/apps/mobile-app/utils/SqliteClient.tsx @@ -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)[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 { + 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 | 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 { + 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 { + 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 { + 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 { + 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 { + return this.folderRepository.moveItem(itemId, folderId); + } + // ============================================================================ // LEGACY: Credential-based methods (kept for backward compatibility) // ============================================================================ diff --git a/apps/mobile-app/utils/db/queries/ItemQueries.ts b/apps/mobile-app/utils/db/queries/ItemQueries.ts index 58cf7fe61..4df11eaf8 100644 --- a/apps/mobile-app/utils/db/queries/ItemQueries.ts +++ b/apps/mobile-app/utils/db/queries/ItemQueries.ts @@ -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`; /** diff --git a/apps/mobile-app/utils/db/repositories/FolderRepository.ts b/apps/mobile-app/utils/db/repositories/FolderRepository.ts new file mode 100644 index 000000000..cd54505af --- /dev/null +++ b/apps/mobile-app/utils/db/repositories/FolderRepository.ts @@ -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 { + 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 { + try { + // Check if table exists first + if (!await this.tableExists('Folders')) { + return []; + } + return this.client.executeQuery(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 | null> { + const results = await this.client.executeQuery>( + 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 { + 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 { + 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 { + 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 { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(FolderQueries.MOVE_ITEM, [ + folderId, + currentDateTime, + itemId + ]); + }); + } +}