From f30cbc1ea37e192e78f809ab4eab82041130ea3e Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Wed, 30 Sep 2026 20:01:44 +0530 Subject: [PATCH 1/4] chore: latest dependency Signed-off-by: Jeel Dobariya --- package-lock.json | 50 +++++++++++++++++++++++------------------------ package.json | 6 +++--- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18197059..f2293dc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@shopify/flash-list": "2.0.2", "babel-plugin-inline-import": "^3.0.0", "drizzle-orm": "^0.45.2", - "expo": "~57.0.25", + "expo": "~57.0.26", "expo-build-properties": "~57.0.22", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.3", @@ -23,8 +23,8 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", "expo-local-authentication": "~57.0.3", - "expo-navigation-bar": "~57.0.2", - "expo-router": "~57.0.23", + "expo-navigation-bar": "~57.0.3", + "expo-router": "~57.0.24", "expo-screen-capture": "~57.0.3", "expo-screen-orientation": "~57.0.2", "expo-sharing": "~57.0.22", @@ -3138,9 +3138,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.20", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.20.tgz", - "integrity": "sha512-0ChmuyWBwEy3JHMR5wDkJGyvkTR5zH3BTwjJrv3f2ymtamhpU9hD5uH4GsLoWuB6MJFOPTuchq4zByZbtgPyNw==", + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.21.tgz", + "integrity": "sha512-4s1ypGNxJhJv2r1fj3wZo4Aubao0zGnCajCuiaTp8pTNrT+1vR8KvyoFEwp+c7gpKm2D58nGCiGBXhEnmKHtDQ==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -7264,9 +7264,9 @@ } }, "node_modules/expo": { - "version": "57.0.25", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.25.tgz", - "integrity": "sha512-oqtX4PekGgU+pBQfsbnbwxh5tKG58o27mN13wTmAhRPlQyK9SHICfCNHxBo+uQyT1BlI3OLhqTwu1qKmHYL5Ug==", + "version": "57.0.26", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.26.tgz", + "integrity": "sha512-U1rsFwcuZNnU61Z/YLnnVC/ZqP0wpxh/rCKUMff3ot/rhLpxFY1zxEqu4vyaexyvBAB1mwpl9lA/ltcHBoQGyg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", @@ -7283,12 +7283,12 @@ "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.13", "expo-asset": "~57.0.18", - "expo-constants": "~57.0.19", + "expo-constants": "~57.0.20", "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", "expo-keep-awake": "~57.0.2", "expo-modules-autolinking": "~57.0.13", - "expo-modules-core": "~57.0.19", + "expo-modules-core": "~57.0.20", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -7405,9 +7405,9 @@ } }, "node_modules/expo-constants": { - "version": "57.0.19", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.19.tgz", - "integrity": "sha512-3eK1kZRwCdMizQLlM/PPlPwjaPnB5qKvdRDMBMnB1ZxmvWJBf9TB4uEO4DMMfIPaFUxYFs29dsR5fPr1gh3FTQ==", + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.20.tgz", + "integrity": "sha512-WycPVvBgbluSa6yAh6DKnX646ZHrae0gV2ZLum5fioNkR+DiWyulq1+UfS++Jc6yfAh/l55NOirv6mt5cnQhSw==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.3" @@ -7599,9 +7599,9 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.19", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.19.tgz", - "integrity": "sha512-eAJwa5u30r4BTT8cFAcrY3809qG1cPszmEci7//+RjWGA1OMXtwirhVY1H+YtCJPyVGs9MuwrIjqQDMReguG2g==", + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.20.tgz", + "integrity": "sha512-vEL3aeih32yuD7sQHB3Y3LKXRT8mMgoVQWFIcHtX811Bl0StnNZUG2vT5je4fe+OWYVfkjb6gNqfMc04moREgA==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", @@ -7629,9 +7629,9 @@ } }, "node_modules/expo-navigation-bar": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-57.0.2.tgz", - "integrity": "sha512-Ufe49dsTZjarA7gMMwwwnNJWpXo+oTyl5Ud0g3lsGVrj2HXjyw9cb9098N8+CoWpjfIa5PghuRYYXrcCrGQIHg==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-57.0.3.tgz", + "integrity": "sha512-+9Q/2OQtpYjObmby1EMpFyhfK5LyT/oHD3lfkU6Z9as8f7arq7vGTlAXzYDP+xK2t76g9TFk//zya56vN5CkzA==", "license": "MIT", "dependencies": { "debug": "^4.3.2" @@ -7643,15 +7643,15 @@ } }, "node_modules/expo-router": { - "version": "57.0.23", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.23.tgz", - "integrity": "sha512-u/iWSdQovEma/owymgz8etxGAif5ZrC8YdXYeGQhDRZwXpAvraordYV0SWej1QWWbYoIkgXcoqHVkgsAff8P4w==", + "version": "57.0.24", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.24.tgz", + "integrity": "sha512-XpqE4Oh4yrmc+sVfsDQPLErBlus//85ulLRghVZltYcdvFm9yNzErmaKrXuBVMUL6D3tnpDtkIgxESG9hYBd7w==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.4", "@expo/metro-runtime": "^57.0.16", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.20", + "@expo/ui": "^57.0.21", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -7681,7 +7681,7 @@ "@expo/metro-runtime": "^57.0.16", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.19", + "expo-constants": "^57.0.20", "expo-linking": "^57.0.11", "react": "*", "react-dom": "*", diff --git a/package.json b/package.json index d49246be..948f27d2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@shopify/flash-list": "2.0.2", "babel-plugin-inline-import": "^3.0.0", "drizzle-orm": "^0.45.2", - "expo": "~57.0.25", + "expo": "~57.0.26", "expo-build-properties": "~57.0.22", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.3", @@ -33,8 +33,8 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.11", "expo-local-authentication": "~57.0.3", - "expo-navigation-bar": "~57.0.2", - "expo-router": "~57.0.23", + "expo-navigation-bar": "~57.0.3", + "expo-router": "~57.0.24", "expo-screen-capture": "~57.0.3", "expo-screen-orientation": "~57.0.2", "expo-sharing": "~57.0.22", From 6d34388f7ebcb1c9f8472ba5fc25318ef44bf044 Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Sat, 3 Oct 2026 18:00:03 +0530 Subject: [PATCH 2/4] feat: add category feature Signed-off-by: Jeel Dobariya --- drizzle.config.ts | 4 +- package.json | 1 + src/app/(tabs)/(password-manager)/_layout.tsx | 10 + .../(password-manager)/category-passwords.tsx | 134 ++++++ src/app/(tabs)/(password-manager)/index.tsx | 435 +++++++++++++++--- .../(password-manager)/manage-categories.tsx | 303 ++++++++++++ .../(password-manager)/password-details.tsx | 1 + .../(password-manager)/save-password.tsx | 16 +- src/components/CategoryField.tsx | 257 +++++++++++ src/components/CategoryNameDialog.tsx | 145 ++++++ src/components/PasswordFormCard.tsx | 10 + src/components/PasswordListItem.tsx | 64 +++ src/contexts/RepositoryContext.tsx | 15 + src/db/drizzle/0001_add_categories.sql | 9 + src/db/drizzle/meta/0001_snapshot.json | 155 +++++++ src/db/drizzle/meta/_journal.json | 7 + src/db/drizzle/migrations.js | 2 + src/db/schema.ts | 75 ++- src/repositories/CategoryRepository.ts | 173 +++++++ src/repositories/PasswordRepository.ts | 29 +- 20 files changed, 1766 insertions(+), 79 deletions(-) create mode 100644 src/app/(tabs)/(password-manager)/category-passwords.tsx create mode 100644 src/app/(tabs)/(password-manager)/manage-categories.tsx create mode 100644 src/components/CategoryField.tsx create mode 100644 src/components/CategoryNameDialog.tsx create mode 100644 src/components/PasswordListItem.tsx create mode 100644 src/db/drizzle/0001_add_categories.sql create mode 100644 src/db/drizzle/meta/0001_snapshot.json create mode 100644 src/repositories/CategoryRepository.ts diff --git a/drizzle.config.ts b/drizzle.config.ts index ee1cc3f2..ff24d2d1 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./db/schema.ts", - out: "./db/drizzle", + schema: "./src/db/schema.ts", + out: "./src/db/drizzle", dialect: "sqlite", driver: "expo", // <--- very important }); diff --git a/package.json b/package.json index 948f27d2..352e4664 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build": "bash scripts/build-all-cloud.sh", "build:develop": "eas build --profile development --platform android --local", "build:preview": "eas build --profile preview --platform android --local", + "db:generate": "drizzle-kit generate", "deploy": "eas build --profile deploy --platform android --local", "deploy:arm64": "eas build --profile deploy-arm64 --platform android --local", "deploy:armv7": "eas build --profile deploy-armv7 --platform android --local", diff --git a/src/app/(tabs)/(password-manager)/_layout.tsx b/src/app/(tabs)/(password-manager)/_layout.tsx index 5c13cdb7..e137fdaf 100644 --- a/src/app/(tabs)/(password-manager)/_layout.tsx +++ b/src/app/(tabs)/(password-manager)/_layout.tsx @@ -10,6 +10,16 @@ export default function PasswordManagerLayout() { options={{ title: "Details", headerShown: true }} /> + + + + (); + + const parsed = Number(categoryIdParam); + const categoryId = Number.isInteger(parsed) && parsed > 0 ? parsed : null; + + const [forceGrid, setForceGrid] = useState(false); + const numColumns = forceGrid ? 2 : 1; + + const passwordRepository = usePasswordRepository(); + const categoryRepository = useCategoryRepository(); + + const { data: passwordList = [] } = useLiveQuery( + passwordRepository.observeByCategory(categoryId), + [categoryId], + ); + + const { data: categoryList = [] } = useLiveQuery( + categoryRepository.observeAll(), + [], + ); + + const title = + categoryId === null + ? "Uncategorized" + : (categoryList.find((category) => category.id === categoryId)?.name ?? + "Category"); + + // Preselect this category on the Add screen (Uncategorized = no param). + function addPassword() { + router.push({ + pathname: "/save-password", + params: categoryId === null ? {} : { categoryId }, + }); + } + + return ( + <> + ( + ( + + )} + onPress={() => { + setForceGrid((g) => !g); + }} + /> + ), + }} + /> + + item.id.toString()} + contentContainerStyle={{ + padding: 20, + }} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + No passwords in this category. + + + + + } + renderItem={({ item }) => } + /> + + ( + + )} + onPress={addPassword} + /> + + ); +} diff --git a/src/app/(tabs)/(password-manager)/index.tsx b/src/app/(tabs)/(password-manager)/index.tsx index a6f28a35..1d4211a1 100644 --- a/src/app/(tabs)/(password-manager)/index.tsx +++ b/src/app/(tabs)/(password-manager)/index.tsx @@ -1,18 +1,46 @@ -import PasswordItemCard from "@/components/PasswordItemCard"; +import PasswordListItem from "@/components/PasswordListItem"; import Text from "@/components/Text"; -import { usePasswordRepository } from "@/contexts/RepositoryContext"; +import { + useCategoryRepository, + usePasswordRepository, +} from "@/contexts/RepositoryContext"; +import type { passwords } from "@/db/schema"; import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; import { FlashList } from "@shopify/flash-list"; import { useLiveQuery } from "drizzle-orm/expo-sqlite"; import { router, Stack } from "expo-router"; import { useMemo, useState } from "react"; -import { Alert, Pressable } from "react-native"; -import { FAB, IconButton, useTheme } from "react-native-paper"; +import { Pressable, StyleSheet, View } from "react-native"; +import { Button, FAB, IconButton, useTheme } from "react-native-paper"; + +type Password = typeof passwords.$inferSelect; + +const UNCATEGORIZED_KEY = "uncategorized"; + +type Row = + | { + kind: "section"; + key: string; + name: string; + count: number; + passwords: Password[]; + expanded: boolean; + } + | { + kind: "category"; + key: string; + categoryId: number | null; + name: string; + count: number; + }; export default function LoadPasswordScreen() { const [forceGrid, setForceGrid] = useState(false); const numColumns = forceGrid ? 2 : 1; + const [viewMode, setViewMode] = useState<"grouped" | "categories">("grouped"); + const [collapsed, setCollapsed] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(""); const [refreshKey, setRefreshKey] = useState(0); const [refreshing, setRefreshing] = useState(false); @@ -20,15 +48,38 @@ export default function LoadPasswordScreen() { const theme = useTheme(); const passwordRepository = usePasswordRepository(); + const categoryRepository = useCategoryRepository(); const { data: passwordList = [] } = useLiveQuery( passwordRepository.observeAll(), [refreshKey], ); - const filteredPasswords = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); + const { data: categoryList = [] } = useLiveQuery( + categoryRepository.observeAll(), + [], + ); + + const query = searchQuery.trim().toLowerCase(); + const isSearching = query !== ""; + + const categoryNameById = useMemo( + () => new Map(categoryList.map((category) => [category.id, category.name])), + [categoryList], + ); + + /** + * A password whose category no longer exists is treated as Uncategorized, + * so it can never silently disappear from the list. + */ + function resolveCategoryId(password: Password): number | null { + return password.categoryId !== null && + categoryNameById.has(password.categoryId) + ? password.categoryId + : null; + } + const filteredPasswords = useMemo(() => { if (!query) { return passwordList; } @@ -38,15 +89,110 @@ export default function LoadPasswordScreen() { const username = password.username?.toLowerCase() ?? ""; const url = password.url?.toLowerCase() ?? ""; const notes = password.notes?.toLowerCase() ?? ""; + const categoryName = ( + (password.categoryId !== null + ? categoryNameById.get(password.categoryId) + : undefined) ?? "Uncategorized" + ).toLowerCase(); return ( domain.includes(query) || username.includes(query) || url.includes(query) || - notes.includes(query) + notes.includes(query) || + categoryName.includes(query) ); }); - }, [passwordList, searchQuery]); + }, [passwordList, query, categoryNameById]); + + /** Grouped view: one collapsible section per non-empty category. */ + const sectionRows = useMemo(() => { + const byCategory = new Map(); + + for (const password of filteredPasswords) { + const categoryId = resolveCategoryId(password); + const bucket = byCategory.get(categoryId); + + if (bucket) { + bucket.push(password); + } else { + byCategory.set(categoryId, [password]); + } + } + + const rows: Row[] = []; + + for (const category of categoryList) { + const items = byCategory.get(category.id); + + if (items && items.length > 0) { + const key = String(category.id); + + rows.push({ + kind: "section", + key, + name: category.name, + count: items.length, + passwords: items, + // While searching, always show the matches. + expanded: isSearching || !collapsed.has(key), + }); + } + } + + const uncategorized = byCategory.get(null); + + if (uncategorized && uncategorized.length > 0) { + rows.push({ + kind: "section", + key: UNCATEGORIZED_KEY, + name: "Uncategorized", + count: uncategorized.length, + passwords: uncategorized, + expanded: isSearching || !collapsed.has(UNCATEGORIZED_KEY), + }); + } + + return rows; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredPasswords, categoryList, categoryNameById, collapsed, isSearching]); + + /** Category view: the categories themselves, with counts (all passwords). */ + const categoryRows = useMemo(() => { + const counts = new Map(); + + for (const password of passwordList) { + const categoryId = resolveCategoryId(password); + counts.set(categoryId, (counts.get(categoryId) ?? 0) + 1); + } + + const rows: Row[] = categoryList.map((category) => ({ + kind: "category", + key: String(category.id), + categoryId: category.id, + name: category.name, + count: counts.get(category.id) ?? 0, + })); + + const uncategorizedCount = counts.get(null) ?? 0; + + if (uncategorizedCount > 0) { + rows.push({ + kind: "category", + key: UNCATEGORIZED_KEY, + categoryId: null, + name: "Uncategorized", + count: uncategorizedCount, + }); + } + + return rows; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [passwordList, categoryList, categoryNameById]); + + // Search finds passwords, so while searching always show grouped results. + const showCategoryView = viewMode === "categories" && !isSearching; + const rows = showCategoryView ? categoryRows : sectionRows; async function onRefresh() { setRefreshing(true); @@ -54,20 +200,27 @@ export default function LoadPasswordScreen() { setRefreshing(false); } - function deletePassword(password: any) { - Alert.alert("Delete?", `${password.domain} : ${password.username}`, [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Delete", - onPress: async () => { - await passwordRepository.delete(password.id); - }, - style: "destructive", + function toggleSection(key: string) { + setCollapsed((current) => { + const next = new Set(current); + + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + + return next; + }); + } + + function openCategory(categoryId: number | null) { + router.push({ + pathname: "/category-passwords", + params: { + categoryId: categoryId === null ? UNCATEGORIZED_KEY : categoryId, }, - ]); + }); } return ( @@ -76,19 +229,47 @@ export default function LoadPasswordScreen() { options={{ title: "Password Manager", headerRight: (props) => ( - ( - + ( + + )} + onPress={() => { + setViewMode((mode) => + mode === "grouped" ? "categories" : "grouped", + ); + }} + /> + + {!showCategoryView && ( + ( + + )} + onPress={() => { + setForceGrid((g) => !g); + }} /> )} - onPress={() => { - setForceGrid((g) => !g); - }} - /> + ), }} > @@ -112,16 +293,48 @@ export default function LoadPasswordScreen() { item.id.toString()} + data={rows} + extraData={numColumns} + keyExtractor={(item) => item.key} + getItemType={(item) => item.kind} refreshing={refreshing} onRefresh={onRefresh} contentContainerStyle={{ padding: 20, }} showsVerticalScrollIndicator={false} + ListHeaderComponent={ + showCategoryView ? ( + + {categoryList.length === 0 && ( + + No categories yet. Create your first category. + + )} + + + + ) : null + } ListEmptyComponent={ - {searchQuery.trim() ? "No passwords found!!" : "No data!!"} + {isSearching ? "No passwords found!!" : "No data!!"} } - renderItem={({ item }) => ( - - router.push({ - pathname: "/password-details", - params: { id: item.id }, - }) - } - onLongPress={() => deletePassword(item)} - > - + item.kind === "section" ? ( + + toggleSection(item.key)} + /> + + {item.expanded && ( + + {item.passwords.map((password) => ( + + + + ))} + + )} + + ) : ( + openCategory(item.categoryId)} /> - - )} + ) + } /> ); } + +// xxxxx Components xxxxx + +function SectionHeader({ + name, + count, + expanded, + disabled, + onPress, +}: { + name: string; + count: number; + expanded: boolean; + disabled: boolean; + onPress: () => void; +}) { + const theme = useTheme(); + + return ( + + + + + + + {name} + + + {count} + + ); +} + +function CategoryRow({ + name, + count, + onPress, +}: { + name: string; + count: number; + onPress: () => void; +}) { + const theme = useTheme(); + + return ( + + + {name} + + + {count} + + + + ); +} diff --git a/src/app/(tabs)/(password-manager)/manage-categories.tsx b/src/app/(tabs)/(password-manager)/manage-categories.tsx new file mode 100644 index 00000000..cb804ffd --- /dev/null +++ b/src/app/(tabs)/(password-manager)/manage-categories.tsx @@ -0,0 +1,303 @@ +import CategoryNameDialog from "@/components/CategoryNameDialog"; +import Text from "@/components/Text"; +import { + useCategoryRepository, + usePasswordRepository, +} from "@/contexts/RepositoryContext"; +import { useToast } from "@/contexts/ToastContext"; +import { + CategoryRepository, + type DeleteCategoryPasswordsMode, +} from "@/repositories/CategoryRepository"; +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; +import { useLocalSearchParams } from "expo-router"; +import { Fragment, useMemo, useState } from "react"; +import { Alert, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + Button, + Card, + Divider, + FAB, + IconButton, + List, + useTheme, +} from "react-native-paper"; +import { SafeAreaView } from "react-native-safe-area-context"; + +type Category = { id: number; name: string }; + +type DialogState = + | { mode: "create" } + | { mode: "rename"; category: Category } + | null; + +function passwordsLabel(count: number) { + return `${count} ${count === 1 ? "password" : "passwords"}`; +} + +export default function ManageCategoriesScreen() { + const { create } = useLocalSearchParams<{ create?: string }>(); + + const theme = useTheme(); + const { showToast } = useToast(); + + const categoryRepository = useCategoryRepository(); + const passwordRepository = usePasswordRepository(); + + const { + data: categoryList = [], + error, + updatedAt, + } = useLiveQuery(categoryRepository.observeAll(), []); + + // Only `category_id` is loaded, enough to count, no password secrets. + const { data: passwordCategoryIds = [] } = useLiveQuery( + passwordRepository.observeCategoryIds(), + [], + ); + + const counts = useMemo( + () => CategoryRepository.countPasswordsByCategory(passwordCategoryIds), + [passwordCategoryIds], + ); + + const [dialog, setDialog] = useState( + create === "1" ? { mode: "create" } : null, + ); + + const isLoading = updatedAt === undefined && !error; + + // xxxxx Delete flow xxxxx + + async function runDelete( + category: Category, + mode: DeleteCategoryPasswordsMode, + count: number, + ) { + try { + await categoryRepository.delete(category.id, mode); + + if (count === 0) { + showToast("Category deleted"); + } else if (mode === "delete") { + showToast(`Category and ${passwordsLabel(count)} deleted`); + } else { + showToast(`Category deleted, ${passwordsLabel(count)} moved to Uncategorized`); + } + } catch (err) { + console.error("Failed to delete category:", err); + showToast("Failed to delete category; please try again", "error"); + } + } + + function confirmDeletePasswords(category: Category, count: number) { + Alert.alert( + `Delete ${passwordsLabel(count)}?`, + `"${category.name}" and its ${passwordsLabel(count)} will be deleted permanently. This can't be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => runDelete(category, "delete", count), + }, + ], + ); + } + + function confirmDelete(category: Category) { + const count = counts.get(category.id) ?? 0; + + if (count === 0) { + Alert.alert("Delete category?", `"${category.name}"`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => runDelete(category, "uncategorize", 0), + }, + ]); + + return; + } + + Alert.alert( + `Delete "${category.name}"?`, + `This category has ${passwordsLabel(count)}. What should happen to them?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete passwords too", + style: "destructive", + onPress: () => confirmDeletePasswords(category, count), + }, + { + // The safe default. + text: "Move to Uncategorized", + onPress: () => runDelete(category, "uncategorize", count), + }, + ], + ); + } + + // xxxxx Render xxxxx + + return ( + + {isLoading ? ( + + ) : error ? ( + + Failed to load categories, please try again!! + + ) : categoryList.length === 0 ? ( + + No categories yet. + + + Create your first category. + + + + + ) : ( + + + {categoryList.map((category, index) => ( + + {index > 0 && } + + ( + + )} + right={() => ( + + ( + + )} + onPress={() => setDialog({ mode: "rename", category })} + /> + + ( + + )} + onPress={() => confirmDelete(category)} + /> + + )} + /> + + ))} + + + + Passwords without a category are shown as Uncategorized. + + + )} + + {!isLoading && !error && categoryList.length > 0 && ( + ( + + )} + onPress={() => setDialog({ mode: "create" })} + /> + )} + + {dialog?.mode === "create" && ( + { + await categoryRepository.create(name); + setDialog(null); + showToast("Category created"); + }} + onCancel={() => setDialog(null)} + /> + )} + + {dialog?.mode === "rename" && ( + { + await categoryRepository.rename(dialog.category.id, name); + setDialog(null); + showToast("Category renamed"); + }} + onCancel={() => setDialog(null)} + /> + )} + + ); +} diff --git a/src/app/(tabs)/(password-manager)/password-details.tsx b/src/app/(tabs)/(password-manager)/password-details.tsx index 8412c0f7..e98836a5 100644 --- a/src/app/(tabs)/(password-manager)/password-details.tsx +++ b/src/app/(tabs)/(password-manager)/password-details.tsx @@ -56,6 +56,7 @@ export default function PasswordDetailsScreen() { domain: password.domain, username: password.username, password: password.password, + categoryId: password.categoryId ?? null, url: password.url || "", notes: password.notes || "", updatedAt: password.updatedAt diff --git a/src/app/(tabs)/(password-manager)/save-password.tsx b/src/app/(tabs)/(password-manager)/save-password.tsx index e337fbde..b06b07dc 100644 --- a/src/app/(tabs)/(password-manager)/save-password.tsx +++ b/src/app/(tabs)/(password-manager)/save-password.tsx @@ -1,10 +1,11 @@ +import CategoryField from "@/components/CategoryField"; import FormTextField from "@/components/FormTextField"; import SecureTextField from "@/components/SecureTextField"; import { usePasswordRepository } from "@/contexts/RepositoryContext"; import { useToast } from "@/contexts/ToastContext"; import { getScreenShotSecureScreen } from "@/libs/screenshot_prevention"; import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; -import { router } from "expo-router"; +import { router, useLocalSearchParams } from "expo-router"; import { useState } from "react"; import { ScrollView, View } from "react-native"; import { Button } from "react-native-paper"; @@ -17,6 +18,16 @@ export default function SavePasswordScreen() { let [url, setUrl] = useState(""); let [notes, setNotes] = useState(""); + // Preselect the category the user was viewing when they pressed "+". + // (missing / invalid param = Uncategorized) + const { categoryId: categoryIdParam } = useLocalSearchParams<{ + categoryId?: string; + }>(); + let [categoryId, setCategoryId] = useState(() => { + const parsed = Number(categoryIdParam); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + }); + const passwordRepository = usePasswordRepository(); async function handleSavePassword() { @@ -32,6 +43,7 @@ export default function SavePasswordScreen() { password, notes, url, + categoryId, }); showToast("Password saved successfully"); @@ -80,6 +92,8 @@ export default function SavePasswordScreen() { placeholderTextColor={"#9e9e9e"} /> + + void; + editable?: boolean; +}; + +/** + * Form field that shows the selected category and, when editable, opens a + * selector (with a "Create Category" step) on press. + * + * Used by BOTH the Add and the Edit password screens so they behave + * identically. + * + * NOTE: Uses React Native's `Modal` (not react-native-paper's Portal based + * `Modal` / `Dialog`) because the Add screen is a native `formSheet`, and + * Paper's Portal renders underneath native screens. + */ +export default function CategoryField({ + value, + onChange, + editable = true, +}: CategoryFieldProps) { + const categoryRepository = useCategoryRepository(); + + const { + data: categoryList = [], + error, + updatedAt, + } = useLiveQuery(categoryRepository.observeAll(), []); + + const [step, setStep] = useState<"closed" | "list" | "create">("closed"); + + const selectedName = + categoryList.find((category) => category.id === value)?.name ?? + "Uncategorized"; + + return ( + <> + setStep("list")} + accessibilityRole="button" + accessibilityLabel={`Category: ${selectedName}`} + accessibilityHint={editable ? "Opens the category list" : undefined} + > + + ( + + )} + /> + ) : undefined + } + /> + + + + {step === "list" && ( + { + onChange(categoryId); + setStep("closed"); + }} + onCreate={() => setStep("create")} + onClose={() => setStep("closed")} + /> + )} + + {step === "create" && ( + { + const created = await categoryRepository.create(name); + + // Select it right away and go back to the password form. + onChange(created.id); + setStep("closed"); + }} + onCancel={() => setStep("list")} + /> + )} + + ); +} + +// xxxxx Selector xxxxx + +type CategorySelectorModalProps = { + value: number | null; + categories: { id: number; name: string }[]; + isLoading: boolean; + hasError: boolean; + onSelect: (categoryId: number | null) => void; + onCreate: () => void; + onClose: () => void; +}; + +function CategorySelectorModal({ + value, + categories, + isLoading, + hasError, + onSelect, + onCreate, + onClose, +}: CategorySelectorModalProps) { + const theme = useTheme(); + + return ( + + + {/* Inner Pressable swallows touches so tapping the sheet doesn't close it. */} + {}} + style={{ + backgroundColor: theme.colors.surface, + padding: 20, + gap: 16, + maxHeight: "80%", + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + }} + > + Select Category + + {isLoading ? ( + + ) : hasError ? ( + + Failed to load categories, please try again!! + + ) : ( + + + onSelect( + selected === UNCATEGORIZED_VALUE ? null : Number(selected), + ) + } + > + + + {categories.map((category) => ( + + ))} + + + {categories.length === 0 && ( + + No categories yet. + + )} + + )} + + + + + + + + + + + + ); +} diff --git a/src/components/CategoryNameDialog.tsx b/src/components/CategoryNameDialog.tsx new file mode 100644 index 00000000..bc643c46 --- /dev/null +++ b/src/components/CategoryNameDialog.tsx @@ -0,0 +1,145 @@ +import FormTextField from "@/components/FormTextField"; +import Text from "@/components/Text"; +import { + CATEGORY_NAME_MAX_LENGTH, + CategoryValidationError, +} from "@/repositories/CategoryRepository"; +import { useState } from "react"; +import { + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + View, +} from "react-native"; +import { Button, HelperText, useTheme } from "react-native-paper"; + +type CategoryNameDialogProps = { + title: string; + confirmLabel: string; + initialName?: string; + /** + * Called with the typed name. Throw to keep the dialog open, a + * `CategoryValidationError` message is shown under the field. + * The PARENT closes the dialog after a successful submit. + */ + onSubmit: (name: string) => Promise; + onCancel: () => void; +}; + +/** + * Name dialog shared by "Create Category" (password form + manage screen) + * and "Rename Category". + * + * Uses React Native's `Modal` (not Paper's Portal based Dialog) so it also + * works on top of the native formSheet of the Add Password screen. + */ +export default function CategoryNameDialog({ + title, + confirmLabel, + initialName = "", + onSubmit, + onCancel, +}: CategoryNameDialogProps) { + const theme = useTheme(); + + const [name, setName] = useState(initialName); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit() { + if (isSubmitting) { + return; + } + + setIsSubmitting(true); + setError(""); + + try { + await onSubmit(name); + } catch (err) { + if (err instanceof CategoryValidationError) { + setError(err.message); + } else { + console.error("Category dialog failed:", err); + setError("Something went wrong, please try again!!"); + } + + setIsSubmitting(false); + } + } + + return ( + + + + {/* Inner Pressable swallows touches so tapping the dialog doesn't close it. */} + {}} + style={{ + backgroundColor: theme.colors.surface, + borderRadius: 20, + padding: 20, + gap: 16, + }} + > + {title} + + + { + setName(text); + setError(""); + }} + placeholder="Work, School, Games..." + placeholderTextColor={"#9e9e9e"} + maxLength={CATEGORY_NAME_MAX_LENGTH} + autoFocus + error={!!error} + onSubmitEditing={handleSubmit} + /> + + + {error} + + + + + + + + + + + + + ); +} diff --git a/src/components/PasswordFormCard.tsx b/src/components/PasswordFormCard.tsx index c750c8c4..124e1722 100644 --- a/src/components/PasswordFormCard.tsx +++ b/src/components/PasswordFormCard.tsx @@ -1,3 +1,4 @@ +import CategoryField from "@/components/CategoryField"; import FormTextField from "@/components/FormTextField"; import SecureTextField from "@/components/SecureTextField"; import { useState } from "react"; @@ -8,6 +9,8 @@ type PasswordFormValue = { domain: string; username: string; password: string; + /** `null` = Uncategorized. */ + categoryId: number | null; url: string; notes: string; updatedAt: string; @@ -55,6 +58,12 @@ export default function PasswordFormCard({ editable={editable} /> + onChange("categoryId", categoryId)} + editable={editable} + /> + ; +}) { + const passwordRepository = usePasswordRepository(); + const { showToast } = useToast(); + + function confirmDelete() { + Alert.alert("Delete?", `${item.domain} : ${item.username}`, [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Delete", + onPress: async () => { + try { + await passwordRepository.delete(item.id); + } catch (error) { + console.error("Failed to delete password:", error); + showToast("Failed to delete; please try again", "error"); + } + }, + style: "destructive", + }, + ]); + } + + return ( + + router.push({ + pathname: "/password-details", + params: { id: item.id }, + }) + } + onLongPress={confirmDelete} + > + + + ); +} diff --git a/src/contexts/RepositoryContext.tsx b/src/contexts/RepositoryContext.tsx index a79c6e3b..1c0e3f89 100644 --- a/src/contexts/RepositoryContext.tsx +++ b/src/contexts/RepositoryContext.tsx @@ -1,10 +1,12 @@ import { createContext, PropsWithChildren, useContext, useMemo } from "react"; import DrizzleDatabaseProvider, { useDrizzleDatabase } from "@/db/provider"; +import { CategoryRepository } from "@/repositories/CategoryRepository"; import { PasswordRepository } from "@/repositories/PasswordRepository"; type Repositories = { password: PasswordRepository; + category: CategoryRepository; }; const RepositoryContext = createContext(undefined); @@ -19,6 +21,7 @@ function RepositoryProviderContent({ const repositories = useMemo( () => ({ password: new PasswordRepository(db), + category: new CategoryRepository(db), }), [db], ); @@ -52,3 +55,15 @@ export function usePasswordRepository(): PasswordRepository { return repositories.password; } + +export function useCategoryRepository(): CategoryRepository { + const repositories = useContext(RepositoryContext); + + if (!repositories) { + throw new Error( + "useCategoryRepository must be used inside RepositoryProvider", + ); + } + + return repositories.category; +} diff --git a/src/db/drizzle/0001_add_categories.sql b/src/db/drizzle/0001_add_categories.sql new file mode 100644 index 00000000..e427f42c --- /dev/null +++ b/src/db/drizzle/0001_add_categories.sql @@ -0,0 +1,9 @@ +CREATE TABLE `categories` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) +); +--> statement-breakpoint +ALTER TABLE `passwords` ADD `category_id` integer REFERENCES categories(id) ON DELETE set null;--> statement-breakpoint +CREATE INDEX `passwords_category_id_idx` ON `passwords` (`category_id`); diff --git a/src/db/drizzle/meta/0001_snapshot.json b/src/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..3b608efe --- /dev/null +++ b/src/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,155 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3bb403b0-e3a6-4130-bab1-4ab2b719aa7d", + "prevId": "f0194a44-f2ad-4fc3-beb8-ddece0e91732", + "tables": { + "passwords": { + "name": "passwords", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passwords_category_id_idx": { + "name": "passwords_category_id_idx", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passwords_category_id_categories_id_fk": { + "name": "passwords_category_id_categories_id_fk", + "tableFrom": "passwords", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/src/db/drizzle/meta/_journal.json b/src/db/drizzle/meta/_journal.json index 0ff33319..179c1385 100644 --- a/src/db/drizzle/meta/_journal.json +++ b/src/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1777726464682, "tag": "0000_init_after_migrating_from_room_android", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1791028800000, + "tag": "0001_add_categories", + "breakpoints": true } ] } diff --git a/src/db/drizzle/migrations.js b/src/db/drizzle/migrations.js index 4ce4f155..760167b8 100644 --- a/src/db/drizzle/migrations.js +++ b/src/db/drizzle/migrations.js @@ -1,11 +1,13 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo import m0000 from "./0000_init_after_migrating_from_room_android.sql"; +import m0001 from "./0001_add_categories.sql"; import journal from "./meta/_journal.json"; export default { journal, migrations: { m0000, + m0001, }, }; diff --git a/src/db/schema.ts b/src/db/schema.ts index 9d720fee..d5e397ae 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,26 +1,61 @@ import { sql } from "drizzle-orm"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + type AnySQLiteColumn, +} from "drizzle-orm/sqlite-core"; -export const passwords = sqliteTable("passwords", { +/** + * Flat password categories (no hierarchy). + * + * "Uncategorized" is NOT a row here, it is the virtual state + * `passwords.category_id IS NULL`. + */ +export const categories = sqliteTable("categories", { id: integer("id").primaryKey({ autoIncrement: true }), - domain: text("domain").notNull(), - username: text("username").notNull(), - password: text("password").notNull(), - notes: text("notes"), - url: text("url"), + name: text("name").notNull(), createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`), updatedAt: text("updated_at").default(sql`(CURRENT_TIMESTAMP)`), - - /* - * Suggested Updates:- - * - * createdAt: text("created_at") - * .notNull() - * .default(sql`CURRENT_TIMESTAMP`), - * - * updatedAt: text("updated_at") - * .notNull() - * .default(sql`CURRENT_TIMESTAMP`) - * .$onUpdateFn(() => sql`CURRENT_TIMESTAMP`), - */ }); + +export const passwords = sqliteTable( + "passwords", + { + id: integer("id").primaryKey({ autoIncrement: true }), + domain: text("domain").notNull(), + username: text("username").notNull(), + password: text("password").notNull(), + notes: text("notes"), + url: text("url"), + createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at").default(sql`(CURRENT_TIMESTAMP)`), + + /* + * NULL = Uncategorized. + * + * NOTE: SQLite only enforces foreign keys when `PRAGMA foreign_keys = ON`. + * The app does not rely on it, CategoryRepository.delete() explicitly + * handles the passwords of a deleted category inside a transaction. + */ + categoryId: integer("category_id").references( + (): AnySQLiteColumn => categories.id, + { onDelete: "set null" }, + ), + + /* + * Suggested Updates:- + * + * createdAt: text("created_at") + * .notNull() + * .default(sql`CURRENT_TIMESTAMP`), + * + * updatedAt: text("updated_at") + * .notNull() + * .default(sql`CURRENT_TIMESTAMP`) + * .$onUpdateFn(() => sql`CURRENT_TIMESTAMP`), + */ + }, + (table) => [index("passwords_category_id_idx").on(table.categoryId)], +); diff --git a/src/repositories/CategoryRepository.ts b/src/repositories/CategoryRepository.ts new file mode 100644 index 00000000..cd351523 --- /dev/null +++ b/src/repositories/CategoryRepository.ts @@ -0,0 +1,173 @@ +import type { DrizzleDatabase } from "@/db/provider"; +import { categories, passwords } from "@/db/schema"; +import { and, asc, eq, ne, sql } from "drizzle-orm"; + +export const CATEGORY_NAME_MAX_LENGTH = 40; + +/** + * Thrown for invalid user input (empty / too long / duplicate name). + * The message is safe to show to the user in a toast. + */ +export class CategoryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "CategoryValidationError"; + } +} + +/** + * What to do with the passwords of a category that is being deleted. + * + * - `uncategorize` (default, safe): passwords stay, `category_id` becomes NULL. + * - `delete`: passwords are permanently deleted (destructive). + */ +export type DeleteCategoryPasswordsMode = "uncategorize" | "delete"; + +export class CategoryRepository { + constructor(private readonly db: DrizzleDatabase) {} + + /** + * Creates a query for observing all categories (sorted by name). + * + * Pass the returned query to `useLiveQuery`. Password counts are + * intentionally NOT joined here: `useLiveQuery` only re-runs when the + * `from()` table changes, so a join would give stale counts. Derive counts + * from the (live) password list instead, see `countPasswordsByCategory`. + */ + observeAll() { + return this.db + .select() + .from(categories) + .orderBy(asc(sql`lower(${categories.name})`), asc(categories.id)); + } + + async getAll() { + return this.observeAll(); + } + + async getById(id: number) { + const [category] = await this.db + .select() + .from(categories) + .where(eq(categories.id, id)); + + return category; + } + + async create(name: string) { + const cleanName = this.validateName(name); + await this.assertNameAvailable(cleanName); + + const [created] = await this.db + .insert(categories) + .values({ name: cleanName }) + .returning(); + + return created!; + } + + /** + * Renames a category. Passwords reference the category by id, so no + * password row is touched. + */ + async rename(id: number, name: string) { + const cleanName = this.validateName(name); + await this.assertNameAvailable(cleanName, id); + + const [updated] = await this.db + .update(categories) + .set({ name: cleanName, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where(eq(categories.id, id)) + .returning(); + + if (!updated) { + throw new Error(`Category ${id} not found`); + } + + return updated; + } + + /** + * Deletes a category inside a single transaction. + * + * Passwords are never deleted unless `mode === "delete"` is passed + * explicitly. + */ + async delete( + id: number, + mode: DeleteCategoryPasswordsMode = "uncategorize", + ) { + this.db.transaction((tx) => { + if (mode === "delete") { + tx.delete(passwords).where(eq(passwords.categoryId, id)).run(); + } else { + tx.update(passwords) + .set({ categoryId: null }) + .where(eq(passwords.categoryId, id)) + .run(); + } + + tx.delete(categories).where(eq(categories.id, id)).run(); + }); + } + + /** + * Number of passwords per category id, with `null` = Uncategorized. + * Pure helper so it can run on the already-loaded live password list + * (no extra query, no N+1). + */ + static countPasswordsByCategory( + passwordList: ReadonlyArray<{ categoryId: number | null }>, + ): Map { + const counts = new Map(); + + for (const { categoryId } of passwordList) { + counts.set(categoryId, (counts.get(categoryId) ?? 0) + 1); + } + + return counts; + } + + // ---------- internals ---------- + + private validateName(name: string): string { + const cleanName = name.trim().replace(/\s+/g, " "); + + if (!cleanName) { + throw new CategoryValidationError("Category name can't be empty."); + } + + if (cleanName.length > CATEGORY_NAME_MAX_LENGTH) { + throw new CategoryValidationError( + `Category name can be at most ${CATEGORY_NAME_MAX_LENGTH} characters.`, + ); + } + + // "Uncategorized" is reserved for the virtual NULL group. + if (cleanName.toLowerCase() === "uncategorized") { + throw new CategoryValidationError( + '"Uncategorized" is reserved, please pick another name.', + ); + } + + return cleanName; + } + + private async assertNameAvailable(name: string, exceptId?: number) { + const sameName = sql`lower(${categories.name}) = lower(${name})`; + + const [existing] = await this.db + .select({ id: categories.id }) + .from(categories) + .where( + exceptId === undefined + ? sameName + : and(sameName, ne(categories.id, exceptId)), + ) + .limit(1); + + if (existing) { + throw new CategoryValidationError("A category with this name exists."); + } + } +} diff --git a/src/repositories/PasswordRepository.ts b/src/repositories/PasswordRepository.ts index ea43a861..8102e451 100644 --- a/src/repositories/PasswordRepository.ts +++ b/src/repositories/PasswordRepository.ts @@ -1,6 +1,6 @@ import type { DrizzleDatabase } from "@/db/provider"; import { passwords } from "@/db/schema"; -import { eq, sql } from "drizzle-orm"; +import { eq, isNull, sql } from "drizzle-orm"; export interface CreatePasswordInput { domain: string; @@ -8,6 +8,8 @@ export interface CreatePasswordInput { password: string; notes?: string | null; url?: string | null; + /** `null` / omitted = Uncategorized. */ + categoryId?: number | null; } export interface UpdatePasswordInput { @@ -16,6 +18,8 @@ export interface UpdatePasswordInput { password?: string; notes?: string | null; url?: string | null; + /** `null` = move to Uncategorized. */ + categoryId?: number | null; } export class PasswordRepository { @@ -31,6 +35,29 @@ export class PasswordRepository { return this.db.select().from(passwords); } + /** + * Creates a query for observing the passwords of ONE category. + * `null` = Uncategorized (`category_id IS NULL`). + */ + observeByCategory(categoryId: number | null) { + return this.db + .select() + .from(passwords) + .where( + categoryId === null + ? isNull(passwords.categoryId) + : eq(passwords.categoryId, categoryId), + ); + } + + /** + * Lightweight query for counting passwords per category. + * Selects ONLY `category_id`, so no password secrets are loaded. + */ + observeCategoryIds() { + return this.db.select({ categoryId: passwords.categoryId }).from(passwords); + } + async getAll() { return this.observeAll(); } From 4cc2d49b9dbae6b619742092363a80db399ced99 Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Sun, 4 Oct 2026 14:41:42 +0530 Subject: [PATCH 3/4] chore(deps): react-native-reanimated Signed-off-by: Jeel Dobariya --- package-lock.json | 26 ++++++++++---------------- package.json | 4 +++- src/config.ts | 4 ++-- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2293dc1..6c702c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,9 +37,11 @@ "react-native": "0.86.3", "react-native-paper": "^5.15.3", "react-native-quick-crypto": "^1.1.7", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", - "react-native-web": "~0.21.0" + "react-native-web": "~0.21.0", + "react-native-worklets": "0.10.1" }, "devDependencies": { "@types/react": "~19.2.10", @@ -541,7 +543,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1014,7 +1015,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1030,7 +1030,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -11834,7 +11833,6 @@ "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11936,11 +11934,10 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.3.tgz", - "integrity": "sha512-+owIckpD4sA13XKHaLrr8V1RP4paqoxKvz/jy2wl/ULjMsfcRlfIJdKJxR1kcQr4McBZ6hAoPo01NyLnmy2ycQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", + "integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==", "license": "MIT", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" @@ -11948,7 +11945,7 @@ "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", - "react-native-worklets": "0.10.x - 0.11.x" + "react-native-worklets": "0.10.x" } }, "node_modules/react-native-reanimated/node_modules/semver": { @@ -11956,7 +11953,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -12021,11 +12017,10 @@ "license": "MIT" }, "node_modules/react-native-worklets": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.3.tgz", - "integrity": "sha512-NaDBNXc2l6p8ggNYJ2iMfD1Yov/PI1CTwL+d0Lczih18C0Tw37mY0sJd9DXi1zZNBeltAsWyZSSr1nOEdren3w==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz", + "integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", @@ -12052,7 +12047,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 352e4664..c4ff0d25 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,11 @@ "react-native": "0.86.3", "react-native-paper": "^5.15.3", "react-native-quick-crypto": "^1.1.7", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", - "react-native-web": "~0.21.0" + "react-native-web": "~0.21.0", + "react-native-worklets": "0.10.1" }, "devDependencies": { "@types/react": "~19.2.10", diff --git a/src/config.ts b/src/config.ts index 9b799d07..d1c68ab8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,12 +5,12 @@ const config = { // Main Config // ---------------------- APP_NAME: "Passcodes", - VERSION: "v4.0.0 - RC.1", + VERSION: "v4.0.0 - RC.2", // ---------------------- // Build Config // ---------------------- - BUILD_VERSION: "v4.0.0-RC.1", // same as VERSION, just without spaces. + BUILD_VERSION: "v4.0.0-RC.2", // same as VERSION, just without spaces. BUILD_ANDROID_VERSIONCODE: 10, BUILD_IOS_VERSIONCODE: "1.0.0", From 6bb9d1c9d9e77d8689471eb895b2d559bca44b78 Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Sun, 4 Oct 2026 14:42:04 +0530 Subject: [PATCH 4/4] feat: improve catogories feature Signed-off-by: Jeel Dobariya --- src/app/(tabs)/(password-manager)/_layout.tsx | 5 - .../(password-manager)/category-passwords.tsx | 118 ++-- src/app/(tabs)/(password-manager)/index.tsx | 505 +++--------------- .../(password-manager)/manage-categories.tsx | 303 ----------- .../(password-manager)/save-password.tsx | 9 +- src/app/(tabs)/(settings)/settings.tsx | 45 +- src/components/AddPasswordFab.tsx | 48 ++ src/components/CategoryField.tsx | 208 +------- src/components/CategoryPickerSheet.tsx | 163 ++++++ src/components/CategorySection.tsx | 152 ++++++ src/components/Collapsible.tsx | 45 ++ src/components/FolderCard.tsx | 59 ++ src/components/FolderList.tsx | 68 +++ src/components/GridToggleButton.tsx | 32 ++ src/components/GroupedPasswordList.tsx | 77 +++ src/components/HomeFab.tsx | 85 +++ src/components/PasswordCardGrid.tsx | 34 ++ src/components/PasswordListItem.tsx | 132 +++-- src/components/SelectionBar.tsx | 168 ++++++ src/components/SpeedDialFab.tsx | 155 ++++++ src/libs/category_actions.tsx | 178 ++++++ src/libs/category_groups.ts | 164 ++++++ src/libs/collapse_store.ts | 34 ++ src/libs/home_layout.ts | 47 ++ src/libs/selection_store.ts | 98 ++++ src/repositories/CategoryRepository.ts | 20 +- src/repositories/PasswordRepository.ts | 58 +- 27 files changed, 1980 insertions(+), 1030 deletions(-) delete mode 100644 src/app/(tabs)/(password-manager)/manage-categories.tsx create mode 100644 src/components/AddPasswordFab.tsx create mode 100644 src/components/CategoryPickerSheet.tsx create mode 100644 src/components/CategorySection.tsx create mode 100644 src/components/Collapsible.tsx create mode 100644 src/components/FolderCard.tsx create mode 100644 src/components/FolderList.tsx create mode 100644 src/components/GridToggleButton.tsx create mode 100644 src/components/GroupedPasswordList.tsx create mode 100644 src/components/HomeFab.tsx create mode 100644 src/components/PasswordCardGrid.tsx create mode 100644 src/components/SelectionBar.tsx create mode 100644 src/components/SpeedDialFab.tsx create mode 100644 src/libs/category_actions.tsx create mode 100644 src/libs/category_groups.ts create mode 100644 src/libs/collapse_store.ts create mode 100644 src/libs/home_layout.ts create mode 100644 src/libs/selection_store.ts diff --git a/src/app/(tabs)/(password-manager)/_layout.tsx b/src/app/(tabs)/(password-manager)/_layout.tsx index e137fdaf..c4a26b1a 100644 --- a/src/app/(tabs)/(password-manager)/_layout.tsx +++ b/src/app/(tabs)/(password-manager)/_layout.tsx @@ -15,11 +15,6 @@ export default function PasswordManagerLayout() { options={{ title: "Category", headerShown: true }} /> - - clearSelection, [])); + useSelectionBackHandler(); + const title = categoryId === null - ? "Uncategorized" + ? UNCATEGORIZED_LABEL : (categoryList.find((category) => category.id === categoryId)?.name ?? - "Category"); + "Folder"); - // Preselect this category on the Add screen (Uncategorized = no param). - function addPassword() { - router.push({ - pathname: "/save-password", - params: categoryId === null ? {} : { categoryId }, - }); - } + const toggleGrid = useCallback(() => setForceGrid((g) => !g), []); + + const visibleIds = useMemo( + () => passwordList.map((password) => password.id), + [passwordList], + ); return ( <> @@ -60,22 +78,34 @@ export default function CategoryPasswordsScreen() { options={{ title, headerRight: (props) => ( - ( - + + + {/* Overflow menu last (far right), like every Android app bar. + Unsorted has none: it can't be renamed or deleted. */} + {categoryId !== null && ( + ( + + )} + onPress={() => + openActions({ id: categoryId, name: title }, () => + router.back(), + ) + } /> )} - onPress={() => { - setForceGrid((g) => !g); - }} - /> + ), }} /> @@ -87,12 +117,13 @@ export default function CategoryPasswordsScreen() { keyExtractor={(item) => item.id.toString()} contentContainerStyle={{ padding: 20, + paddingBottom: 110, // room for the FAB / selection bar }} showsVerticalScrollIndicator={false} ListEmptyComponent={ - No passwords in this category. + No passwords in this folder. @@ -113,22 +152,11 @@ export default function CategoryPasswordsScreen() { renderItem={({ item }) => } /> - ( - - )} - onPress={addPassword} - /> + + + + + {dialogElement} ); } diff --git a/src/app/(tabs)/(password-manager)/index.tsx b/src/app/(tabs)/(password-manager)/index.tsx index 1d4211a1..a19f60f8 100644 --- a/src/app/(tabs)/(password-manager)/index.tsx +++ b/src/app/(tabs)/(password-manager)/index.tsx @@ -1,276 +1,81 @@ -import PasswordListItem from "@/components/PasswordListItem"; -import Text from "@/components/Text"; +import FolderList from "@/components/FolderList"; +import GridToggleButton from "@/components/GridToggleButton"; +import GroupedPasswordList from "@/components/GroupedPasswordList"; +import HomeFab from "@/components/HomeFab"; +import SelectionBar from "@/components/SelectionBar"; +import { useCategoryActions, type CategoryNoun } from "@/libs/category_actions"; +import { UNCATEGORIZED_KEY, useCategoryGroups } from "@/libs/category_groups"; +import { useHomeLayout } from "@/libs/home_layout"; import { - useCategoryRepository, - usePasswordRepository, -} from "@/contexts/RepositoryContext"; -import type { passwords } from "@/db/schema"; -import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; -import { FlashList } from "@shopify/flash-list"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; -import { router, Stack } from "expo-router"; -import { useMemo, useState } from "react"; -import { Pressable, StyleSheet, View } from "react-native"; -import { Button, FAB, IconButton, useTheme } from "react-native-paper"; - -type Password = typeof passwords.$inferSelect; - -const UNCATEGORIZED_KEY = "uncategorized"; - -type Row = - | { - kind: "section"; - key: string; - name: string; - count: number; - passwords: Password[]; - expanded: boolean; - } - | { - kind: "category"; - key: string; - categoryId: number | null; - name: string; - count: number; - }; + clearSelection, + useSelectionBackHandler, +} from "@/libs/selection_store"; +import { router, Stack, useFocusEffect } from "expo-router"; +import { useCallback, useState } from "react"; +import { useTheme } from "react-native-paper"; export default function LoadPasswordScreen() { + const layout = useHomeLayout(); + const noun: CategoryNoun = layout === "folders" ? "folder" : "category"; + const [forceGrid, setForceGrid] = useState(false); const numColumns = forceGrid ? 2 : 1; - const [viewMode, setViewMode] = useState<"grouped" | "categories">("grouped"); - const [collapsed, setCollapsed] = useState>(new Set()); - const [searchQuery, setSearchQuery] = useState(""); const [refreshKey, setRefreshKey] = useState(0); const [refreshing, setRefreshing] = useState(false); const theme = useTheme(); - const passwordRepository = usePasswordRepository(); - const categoryRepository = useCategoryRepository(); - - const { data: passwordList = [] } = useLiveQuery( - passwordRepository.observeAll(), - [refreshKey], - ); - - const { data: categoryList = [] } = useLiveQuery( - categoryRepository.observeAll(), - [], - ); - const query = searchQuery.trim().toLowerCase(); const isSearching = query !== ""; - const categoryNameById = useMemo( - () => new Map(categoryList.map((category) => [category.id, category.name])), - [categoryList], - ); - - /** - * A password whose category no longer exists is treated as Uncategorized, - * so it can never silently disappear from the list. - */ - function resolveCategoryId(password: Password): number | null { - return password.categoryId !== null && - categoryNameById.has(password.categoryId) - ? password.categoryId - : null; - } - - const filteredPasswords = useMemo(() => { - if (!query) { - return passwordList; - } - - return passwordList.filter((password) => { - const domain = password.domain?.toLowerCase() ?? ""; - const username = password.username?.toLowerCase() ?? ""; - const url = password.url?.toLowerCase() ?? ""; - const notes = password.notes?.toLowerCase() ?? ""; - const categoryName = ( - (password.categoryId !== null - ? categoryNameById.get(password.categoryId) - : undefined) ?? "Uncategorized" - ).toLowerCase(); - - return ( - domain.includes(query) || - username.includes(query) || - url.includes(query) || - notes.includes(query) || - categoryName.includes(query) - ); - }); - }, [passwordList, query, categoryNameById]); - - /** Grouped view: one collapsible section per non-empty category. */ - const sectionRows = useMemo(() => { - const byCategory = new Map(); - - for (const password of filteredPasswords) { - const categoryId = resolveCategoryId(password); - const bucket = byCategory.get(categoryId); - - if (bucket) { - bucket.push(password); - } else { - byCategory.set(categoryId, [password]); - } - } - - const rows: Row[] = []; - - for (const category of categoryList) { - const items = byCategory.get(category.id); - - if (items && items.length > 0) { - const key = String(category.id); - - rows.push({ - kind: "section", - key, - name: category.name, - count: items.length, - passwords: items, - // While searching, always show the matches. - expanded: isSearching || !collapsed.has(key), - }); - } - } + // Search finds passwords, so while searching always show the matches. + const showFolders = layout === "folders" && !isSearching; - const uncategorized = byCategory.get(null); - - if (uncategorized && uncategorized.length > 0) { - rows.push({ - kind: "section", - key: UNCATEGORIZED_KEY, - name: "Uncategorized", - count: uncategorized.length, - passwords: uncategorized, - expanded: isSearching || !collapsed.has(UNCATEGORIZED_KEY), - }); - } - - return rows; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredPasswords, categoryList, categoryNameById, collapsed, isSearching]); - - /** Category view: the categories themselves, with counts (all passwords). */ - const categoryRows = useMemo(() => { - const counts = new Map(); - - for (const password of passwordList) { - const categoryId = resolveCategoryId(password); - counts.set(categoryId, (counts.get(categoryId) ?? 0) + 1); - } - - const rows: Row[] = categoryList.map((category) => ({ - kind: "category", - key: String(category.id), - categoryId: category.id, - name: category.name, - count: counts.get(category.id) ?? 0, - })); - - const uncategorizedCount = counts.get(null) ?? 0; + const { groups, folders, visibleIds, hasCategories } = useCategoryGroups( + query, + refreshKey, + ); - if (uncategorizedCount > 0) { - rows.push({ - kind: "category", - key: UNCATEGORIZED_KEY, - categoryId: null, - name: "Uncategorized", - count: uncategorizedCount, - }); - } + const { openCreate, openActions, dialogElement } = useCategoryActions(noun); - return rows; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [passwordList, categoryList, categoryNameById]); + // Leaving the screen ends multi-select; Android back leaves it first. + useFocusEffect(useCallback(() => clearSelection, [])); + useSelectionBackHandler(); - // Search finds passwords, so while searching always show grouped results. - const showCategoryView = viewMode === "categories" && !isSearching; - const rows = showCategoryView ? categoryRows : sectionRows; + const toggleGrid = useCallback(() => setForceGrid((g) => !g), []); - async function onRefresh() { + const onRefresh = useCallback(async () => { setRefreshing(true); setRefreshKey((k) => k + 1); setRefreshing(false); - } + }, []); - function toggleSection(key: string) { - setCollapsed((current) => { - const next = new Set(current); - - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - - return next; - }); - } - - function openCategory(categoryId: number | null) { + const onOpenFolder = useCallback((categoryId: number | null) => { router.push({ pathname: "/category-passwords", params: { categoryId: categoryId === null ? UNCATEGORIZED_KEY : categoryId, }, }); - } + }, []); return ( <> ( - - ( - - )} - onPress={() => { - setViewMode((mode) => - mode === "grouped" ? "categories" : "grouped", - ); - }} + // Always set (never omitted): the header keeps old options + // otherwise. Folders have no list/grid switch, so render nothing. + headerRight: (props) => + showFolders ? null : ( + - - {!showCategoryView && ( - ( - - )} - onPress={() => { - setForceGrid((g) => !g); - }} - /> - )} - - ), + ), }} > Password Manager @@ -292,212 +97,34 @@ export default function LoadPasswordScreen() { /> - item.key} - getItemType={(item) => item.kind} - refreshing={refreshing} - onRefresh={onRefresh} - contentContainerStyle={{ - padding: 20, - }} - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - showCategoryView ? ( - - {categoryList.length === 0 && ( - - No categories yet. Create your first category. - - )} - - - - ) : null - } - ListEmptyComponent={ - - {isSearching ? "No passwords found!!" : "No data!!"} - - } - renderItem={({ item }) => - item.kind === "section" ? ( - - toggleSection(item.key)} - /> - - {item.expanded && ( - - {item.passwords.map((password) => ( - - - - ))} - - )} - - ) : ( - openCategory(item.categoryId)} - /> - ) - } - /> - - ( - - )} - onPress={() => { - router.push("/save-password"); - }} - /> - - ); -} - -// xxxxx Components xxxxx - -function SectionHeader({ - name, - count, - expanded, - disabled, - onPress, -}: { - name: string; - count: number; - expanded: boolean; - disabled: boolean; - onPress: () => void; -}) { - const theme = useTheme(); - - return ( - - - - - - - {name} - - - {count} - - ); -} - -function CategoryRow({ - name, - count, - onPress, -}: { - name: string; - count: number; - onPress: () => void; -}) { - const theme = useTheme(); + ) : ( + <> + - return ( - - - {name} - + + + )} - {count} + - - + {dialogElement} + ); } diff --git a/src/app/(tabs)/(password-manager)/manage-categories.tsx b/src/app/(tabs)/(password-manager)/manage-categories.tsx deleted file mode 100644 index cb804ffd..00000000 --- a/src/app/(tabs)/(password-manager)/manage-categories.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import CategoryNameDialog from "@/components/CategoryNameDialog"; -import Text from "@/components/Text"; -import { - useCategoryRepository, - usePasswordRepository, -} from "@/contexts/RepositoryContext"; -import { useToast } from "@/contexts/ToastContext"; -import { - CategoryRepository, - type DeleteCategoryPasswordsMode, -} from "@/repositories/CategoryRepository"; -import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; -import { useLocalSearchParams } from "expo-router"; -import { Fragment, useMemo, useState } from "react"; -import { Alert, ScrollView, View } from "react-native"; -import { - ActivityIndicator, - Button, - Card, - Divider, - FAB, - IconButton, - List, - useTheme, -} from "react-native-paper"; -import { SafeAreaView } from "react-native-safe-area-context"; - -type Category = { id: number; name: string }; - -type DialogState = - | { mode: "create" } - | { mode: "rename"; category: Category } - | null; - -function passwordsLabel(count: number) { - return `${count} ${count === 1 ? "password" : "passwords"}`; -} - -export default function ManageCategoriesScreen() { - const { create } = useLocalSearchParams<{ create?: string }>(); - - const theme = useTheme(); - const { showToast } = useToast(); - - const categoryRepository = useCategoryRepository(); - const passwordRepository = usePasswordRepository(); - - const { - data: categoryList = [], - error, - updatedAt, - } = useLiveQuery(categoryRepository.observeAll(), []); - - // Only `category_id` is loaded, enough to count, no password secrets. - const { data: passwordCategoryIds = [] } = useLiveQuery( - passwordRepository.observeCategoryIds(), - [], - ); - - const counts = useMemo( - () => CategoryRepository.countPasswordsByCategory(passwordCategoryIds), - [passwordCategoryIds], - ); - - const [dialog, setDialog] = useState( - create === "1" ? { mode: "create" } : null, - ); - - const isLoading = updatedAt === undefined && !error; - - // xxxxx Delete flow xxxxx - - async function runDelete( - category: Category, - mode: DeleteCategoryPasswordsMode, - count: number, - ) { - try { - await categoryRepository.delete(category.id, mode); - - if (count === 0) { - showToast("Category deleted"); - } else if (mode === "delete") { - showToast(`Category and ${passwordsLabel(count)} deleted`); - } else { - showToast(`Category deleted, ${passwordsLabel(count)} moved to Uncategorized`); - } - } catch (err) { - console.error("Failed to delete category:", err); - showToast("Failed to delete category; please try again", "error"); - } - } - - function confirmDeletePasswords(category: Category, count: number) { - Alert.alert( - `Delete ${passwordsLabel(count)}?`, - `"${category.name}" and its ${passwordsLabel(count)} will be deleted permanently. This can't be undone.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: () => runDelete(category, "delete", count), - }, - ], - ); - } - - function confirmDelete(category: Category) { - const count = counts.get(category.id) ?? 0; - - if (count === 0) { - Alert.alert("Delete category?", `"${category.name}"`, [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: () => runDelete(category, "uncategorize", 0), - }, - ]); - - return; - } - - Alert.alert( - `Delete "${category.name}"?`, - `This category has ${passwordsLabel(count)}. What should happen to them?`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete passwords too", - style: "destructive", - onPress: () => confirmDeletePasswords(category, count), - }, - { - // The safe default. - text: "Move to Uncategorized", - onPress: () => runDelete(category, "uncategorize", count), - }, - ], - ); - } - - // xxxxx Render xxxxx - - return ( - - {isLoading ? ( - - ) : error ? ( - - Failed to load categories, please try again!! - - ) : categoryList.length === 0 ? ( - - No categories yet. - - - Create your first category. - - - - - ) : ( - - - {categoryList.map((category, index) => ( - - {index > 0 && } - - ( - - )} - right={() => ( - - ( - - )} - onPress={() => setDialog({ mode: "rename", category })} - /> - - ( - - )} - onPress={() => confirmDelete(category)} - /> - - )} - /> - - ))} - - - - Passwords without a category are shown as Uncategorized. - - - )} - - {!isLoading && !error && categoryList.length > 0 && ( - ( - - )} - onPress={() => setDialog({ mode: "create" })} - /> - )} - - {dialog?.mode === "create" && ( - { - await categoryRepository.create(name); - setDialog(null); - showToast("Category created"); - }} - onCancel={() => setDialog(null)} - /> - )} - - {dialog?.mode === "rename" && ( - { - await categoryRepository.rename(dialog.category.id, name); - setDialog(null); - showToast("Category renamed"); - }} - onCancel={() => setDialog(null)} - /> - )} - - ); -} diff --git a/src/app/(tabs)/(password-manager)/save-password.tsx b/src/app/(tabs)/(password-manager)/save-password.tsx index b06b07dc..0e32456b 100644 --- a/src/app/(tabs)/(password-manager)/save-password.tsx +++ b/src/app/(tabs)/(password-manager)/save-password.tsx @@ -20,8 +20,11 @@ export default function SavePasswordScreen() { // Preselect the category the user was viewing when they pressed "+". // (missing / invalid param = Uncategorized) - const { categoryId: categoryIdParam } = useLocalSearchParams<{ + // `lockCategory=1` (opened from inside a folder): the folder is the + // category, so the field is hidden. + const { categoryId: categoryIdParam, lockCategory } = useLocalSearchParams<{ categoryId?: string; + lockCategory?: string; }>(); let [categoryId, setCategoryId] = useState(() => { const parsed = Number(categoryIdParam); @@ -92,7 +95,9 @@ export default function SavePasswordScreen() { placeholderTextColor={"#9e9e9e"} /> - + {lockCategory !== "1" && ( + + )} + + + ( + + )} + /> + + setHomeLayout(value as HomeLayout)} + buttons={[ + { value: "grouped", label: "Grouped list" }, + { value: "folders", label: "Folders" }, + ]} + /> + + + stable identity, the FAB never re-renders because of it. +const PlusIcon = ({ size, color }: { size: number; color: string }) => ( + +); + +/** + * "Add password" FAB. `categoryId` (a number, or null for Uncategorized) + * pre-selects that category and hides the Category field on the Add screen. + * Hidden while multi-selecting. + */ +function AddPasswordFab({ categoryId }: { categoryId: number | null }) { + const isSelectingNow = useIsSelecting(); + + const handlePress = useCallback(() => { + router.push({ + pathname: "/save-password", + params: + categoryId === null + ? { lockCategory: "1" } + : { categoryId, lockCategory: "1" }, + }); + }, [categoryId]); + + if (isSelectingNow) { + return null; + } + + return ( + + ); +} + +export default memo(AddPasswordFab); diff --git a/src/components/CategoryField.tsx b/src/components/CategoryField.tsx index aaeaaa17..20fac2c7 100644 --- a/src/components/CategoryField.tsx +++ b/src/components/CategoryField.tsx @@ -1,21 +1,12 @@ -import CategoryNameDialog from "@/components/CategoryNameDialog"; +import CategoryPickerSheet from "@/components/CategoryPickerSheet"; import FormTextField from "@/components/FormTextField"; -import Text from "@/components/Text"; import { useCategoryRepository } from "@/contexts/RepositoryContext"; +import { UNCATEGORIZED_LABEL } from "@/repositories/CategoryRepository"; import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; import { useLiveQuery } from "drizzle-orm/expo-sqlite"; import { useState } from "react"; -import { Modal, Pressable, ScrollView, View } from "react-native"; -import { - ActivityIndicator, - Button, - Divider, - RadioButton, - TextInput, - useTheme, -} from "react-native-paper"; - -const UNCATEGORIZED_VALUE = "uncategorized"; +import { Pressable, View } from "react-native"; +import { TextInput } from "react-native-paper"; type CategoryFieldProps = { /** Selected category id, `null` = Uncategorized. */ @@ -25,15 +16,9 @@ type CategoryFieldProps = { }; /** - * Form field that shows the selected category and, when editable, opens a - * selector (with a "Create Category" step) on press. - * - * Used by BOTH the Add and the Edit password screens so they behave - * identically. - * - * NOTE: Uses React Native's `Modal` (not react-native-paper's Portal based - * `Modal` / `Dialog`) because the Add screen is a native `formSheet`, and - * Paper's Portal renders underneath native screens. + * Select-only Category field, shared by the Add and the Edit password + * screens so they behave identically. Categories are created from the home + * screen, not here. */ export default function CategoryField({ value, @@ -42,23 +27,22 @@ export default function CategoryField({ }: CategoryFieldProps) { const categoryRepository = useCategoryRepository(); - const { - data: categoryList = [], - error, - updatedAt, - } = useLiveQuery(categoryRepository.observeAll(), []); + const { data: categoryList = [] } = useLiveQuery( + categoryRepository.observeAll(), + [], + ); - const [step, setStep] = useState<"closed" | "list" | "create">("closed"); + const [isOpen, setIsOpen] = useState(false); const selectedName = categoryList.find((category) => category.id === value)?.name ?? - "Uncategorized"; + UNCATEGORIZED_LABEL; return ( <> setStep("list")} + onPress={() => setIsOpen(true)} accessibilityRole="button" accessibilityLabel={`Category: ${selectedName}`} accessibilityHint={editable ? "Opens the category list" : undefined} @@ -86,172 +70,16 @@ export default function CategoryField({ - {step === "list" && ( - { onChange(categoryId); - setStep("closed"); - }} - onCreate={() => setStep("create")} - onClose={() => setStep("closed")} - /> - )} - - {step === "create" && ( - { - const created = await categoryRepository.create(name); - - // Select it right away and go back to the password form. - onChange(created.id); - setStep("closed"); + setIsOpen(false); }} - onCancel={() => setStep("list")} + onClose={() => setIsOpen(false)} /> )} ); } - -// xxxxx Selector xxxxx - -type CategorySelectorModalProps = { - value: number | null; - categories: { id: number; name: string }[]; - isLoading: boolean; - hasError: boolean; - onSelect: (categoryId: number | null) => void; - onCreate: () => void; - onClose: () => void; -}; - -function CategorySelectorModal({ - value, - categories, - isLoading, - hasError, - onSelect, - onCreate, - onClose, -}: CategorySelectorModalProps) { - const theme = useTheme(); - - return ( - - - {/* Inner Pressable swallows touches so tapping the sheet doesn't close it. */} - {}} - style={{ - backgroundColor: theme.colors.surface, - padding: 20, - gap: 16, - maxHeight: "80%", - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - }} - > - Select Category - - {isLoading ? ( - - ) : hasError ? ( - - Failed to load categories, please try again!! - - ) : ( - - - onSelect( - selected === UNCATEGORIZED_VALUE ? null : Number(selected), - ) - } - > - - - {categories.map((category) => ( - - ))} - - - {categories.length === 0 && ( - - No categories yet. - - )} - - )} - - - - - - - - - - - - ); -} diff --git a/src/components/CategoryPickerSheet.tsx b/src/components/CategoryPickerSheet.tsx new file mode 100644 index 00000000..6a2e22a0 --- /dev/null +++ b/src/components/CategoryPickerSheet.tsx @@ -0,0 +1,163 @@ +import Text from "@/components/Text"; +import { useCategoryRepository } from "@/contexts/RepositoryContext"; +import { UNCATEGORIZED_LABEL } from "@/repositories/CategoryRepository"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + Button, + Divider, + RadioButton, + useTheme, +} from "react-native-paper"; + +const UNCATEGORIZED_VALUE = "uncategorized"; + +type CategoryPickerSheetProps = { + title?: string; + /** Currently selected id, `null` = Uncategorized, `undefined` = none. */ + value?: number | null; + onSelect: (categoryId: number | null, name: string) => void; + onClose: () => void; +}; + +/** + * Bottom sheet that lists Uncategorized + all categories (select only, + * categories are created from the home screen). + * + * Shared by the Category field (Add / Edit password) and by "Move" in the + * multi-select bar. + * + * Uses React Native's `Modal` because Paper's Portal renders underneath the + * native formSheet of the Add screen. + */ +export default function CategoryPickerSheet({ + title = "Select Category", + value, + onSelect, + onClose, +}: CategoryPickerSheetProps) { + const theme = useTheme(); + const categoryRepository = useCategoryRepository(); + + const { + data: categories = [], + error, + updatedAt, + } = useLiveQuery(categoryRepository.observeAll(), []); + + const isLoading = updatedAt === undefined && !error; + + return ( + + + {/* Inner Pressable swallows touches so tapping the sheet doesn't close it. */} + {}} + style={{ + backgroundColor: theme.colors.surface, + padding: 20, + gap: 16, + maxHeight: "80%", + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + }} + > + {title} + + {isLoading ? ( + + ) : error ? ( + + Failed to load categories, please try again!! + + ) : ( + + { + if (selected === UNCATEGORIZED_VALUE) { + onSelect(null, UNCATEGORIZED_LABEL); + return; + } + + const id = Number(selected); + const name = + categories.find((category) => category.id === id)?.name ?? + "category"; + + onSelect(id, name); + }} + > + + + {categories.map((category) => ( + + ))} + + + {categories.length === 0 && ( + + No categories yet. Create one from the home screen. + + )} + + )} + + + + + + + + + + ); +} diff --git a/src/components/CategorySection.tsx b/src/components/CategorySection.tsx new file mode 100644 index 00000000..fc5622d6 --- /dev/null +++ b/src/components/CategorySection.tsx @@ -0,0 +1,152 @@ +import Collapsible from "@/components/Collapsible"; +import PasswordCardGrid from "@/components/PasswordCardGrid"; +import Text from "@/components/Text"; +import { toggleCollapsed, useIsCollapsed } from "@/libs/collapse_store"; +import type { CategoryRef } from "@/libs/category_actions"; +import type { CategoryGroup } from "@/libs/category_groups"; +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; +import { memo, useEffect } from "react"; +import { Pressable, StyleSheet, View } from "react-native"; +import { IconButton, useTheme } from "react-native-paper"; +import Animated, { + LinearTransition, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +type CategorySectionProps = { + group: CategoryGroup; + numColumns: number; + /** While searching, matches are always shown. */ + forceExpanded: boolean; + onOpenActions: (category: CategoryRef) => void; +}; + +/** + * One collapsible category in the grouped layout: header (chevron, name, + * count, actions) + animated body. + * + * Collapsed state comes from the collapse store, so toggling re-renders only + * this section. + */ +function CategorySection({ + group, + numColumns, + forceExpanded, + onOpenActions, +}: CategorySectionProps) { + const theme = useTheme(); + const collapsed = useIsCollapsed(group.key); + const expanded = forceExpanded || !collapsed; + + const { categoryId, name, count } = group; + + return ( + + + toggleCollapsed(group.key)} + accessibilityRole="button" + accessibilityState={{ expanded }} + accessibilityLabel={`${name}, ${count} ${count === 1 ? "password" : "passwords"}`} + style={{ + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 4, + }} + > + + + + {name} + + + {count} + + + {categoryId !== null && ( + ( + + )} + onPress={() => onOpenActions({ id: categoryId, name })} + /> + )} + + + + {group.passwords.length > 0 ? ( + + ) : ( + + No passwords in this category. + + )} + + + ); +} + +function AnimatedChevron({ + expanded, + color, +}: { + expanded: boolean; + color: string; +}) { + const rotation = useSharedValue(expanded ? 1 : 0); + + useEffect(() => { + rotation.value = withTiming(expanded ? 1 : 0, { duration: 220 }); + }, [expanded, rotation]); + + const style = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value * 90}deg` }], + })); + + return ( + + + + ); +} + +export default memo(CategorySection); diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx new file mode 100644 index 00000000..13390df9 --- /dev/null +++ b/src/components/Collapsible.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import Animated, { + FadeIn, + FadeOut, + LinearTransition, +} from "react-native-reanimated"; + +const DURATION = 220; + +/** + * Animated expand / collapse built on Reanimated layout animations: + * + * - the content fades in / out and is really unmounted when collapsed, so a + * collapsed section costs nothing to render + * - the wrapper animates its own height (`LinearTransition`) + * + * No manual measuring / timers. Put `layout={LinearTransition}` on the + * siblings too (see `CategorySection`) so they slide along smoothly. + * + * Wrap the list in `` to avoid + * animating the very first render. + */ +export default function Collapsible({ + expanded, + children, +}: { + expanded: boolean; + children: ReactNode; +}) { + return ( + + {expanded && ( + + {children} + + )} + + ); +} diff --git a/src/components/FolderCard.tsx b/src/components/FolderCard.tsx new file mode 100644 index 00000000..1029afe9 --- /dev/null +++ b/src/components/FolderCard.tsx @@ -0,0 +1,59 @@ +import Text from "@/components/Text"; +import type { FolderData } from "@/libs/category_groups"; +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; +import { memo } from "react"; +import { Pressable } from "react-native"; +import { useTheme } from "react-native-paper"; + +type FolderCardProps = { + folder: FolderData; + onOpen: (categoryId: number | null) => void; + onOpenActions: (category: { id: number; name: string }) => void; +}; + +/** One folder tile: tap = open, long press = rename / delete. */ +function FolderCard({ folder, onOpen, onOpenActions }: FolderCardProps) { + const theme = useTheme(); + const { categoryId, name, count } = folder; + + return ( + onOpen(categoryId)} + onLongPress={ + categoryId === null + ? undefined // Uncategorized can't be renamed / deleted + : () => onOpenActions({ id: categoryId, name }) + } + accessibilityRole="button" + accessibilityLabel={`${name}, ${count} ${count === 1 ? "password" : "passwords"}`} + accessibilityHint={ + categoryId === null ? undefined : "Long press for rename and delete" + } + style={{ + flex: 1, + margin: 2, + gap: 8, + backgroundColor: theme.colors.surface, + borderRadius: 16, + padding: 16, + }} + > + + + + {name} + + + + {count} {count === 1 ? "password" : "passwords"} + + + ); +} + +export default memo(FolderCard); diff --git a/src/components/FolderList.tsx b/src/components/FolderList.tsx new file mode 100644 index 00000000..9d1d5c40 --- /dev/null +++ b/src/components/FolderList.tsx @@ -0,0 +1,68 @@ +import FolderCard from "@/components/FolderCard"; +import Text from "@/components/Text"; +import type { CategoryRef } from "@/libs/category_actions"; +import type { FolderData } from "@/libs/category_groups"; +import { FlashList } from "@shopify/flash-list"; +import { memo, useCallback } from "react"; + +type FolderListProps = { + folders: FolderData[]; + hasCategories: boolean; + refreshing: boolean; + onRefresh: () => void; + onOpenFolder: (categoryId: number | null) => void; + onOpenActions: (category: CategoryRef) => void; +}; + +/** Folders layout: a 2-column grid of folders. */ +function FolderList({ + folders, + hasCategories, + refreshing, + onRefresh, + onOpenFolder, + onOpenActions, +}: FolderListProps) { + const renderItem = useCallback( + ({ item }: { item: FolderData }) => ( + + ), + [onOpenFolder, onOpenActions], + ); + + return ( + item.key} + renderItem={renderItem} + refreshing={refreshing} + onRefresh={onRefresh} + contentContainerStyle={{ + padding: 20, + paddingBottom: 110, // room for the FAB + }} + showsVerticalScrollIndicator={false} + ListHeaderComponent={ + hasCategories ? null : ( + + No folders yet. Tap + to create your first folder. + + ) + } + /> + ); +} + +export default memo(FolderList); diff --git a/src/components/GridToggleButton.tsx b/src/components/GridToggleButton.tsx new file mode 100644 index 00000000..f7da40b0 --- /dev/null +++ b/src/components/GridToggleButton.tsx @@ -0,0 +1,32 @@ +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; +import type { ColorValue } from "react-native"; +import { IconButton } from "react-native-paper"; + +/** + * Appbar button that switches a password list between 1 column (list) and + * 2 columns (grid). Shared by the home screen and the folder screen. + */ +export default function GridToggleButton({ + isGrid, + onPress, + color, +}: { + isGrid: boolean; + onPress: () => void; + color?: ColorValue | undefined; +}) { + return ( + ( + + )} + onPress={onPress} + /> + ); +} diff --git a/src/components/GroupedPasswordList.tsx b/src/components/GroupedPasswordList.tsx new file mode 100644 index 00000000..afd4638f --- /dev/null +++ b/src/components/GroupedPasswordList.tsx @@ -0,0 +1,77 @@ +import CategorySection from "@/components/CategorySection"; +import Text from "@/components/Text"; +import type { CategoryRef } from "@/libs/category_actions"; +import type { CategoryGroup } from "@/libs/category_groups"; +import { memo } from "react"; +import { RefreshControl, ScrollView } from "react-native"; +import { LayoutAnimationConfig } from "react-native-reanimated"; + +type GroupedPasswordListProps = { + groups: CategoryGroup[]; + numColumns: number; + forceExpanded: boolean; + refreshing: boolean; + onRefresh: () => void; + emptyText: string; + onOpenActions: (category: CategoryRef) => void; +}; + +/** + * Grouped layout: collapsible category sections. + * + * A plain ScrollView on purpose: accordion animations (height changes of + * rows) are reliable here, while a recycling list (FlashList) fights with + * them. Collapsed sections unmount their cards, and each card is memoized, + * so this stays cheap for a normal password vault. + */ +function GroupedPasswordList({ + groups, + numColumns, + forceExpanded, + refreshing, + onRefresh, + emptyText, + onOpenActions, +}: GroupedPasswordListProps) { + return ( + + } + > + {/* skipEntering: don't animate the very first render of the list. */} + + {groups.length === 0 ? ( + + {emptyText} + + ) : ( + groups.map((group) => ( + + )) + )} + + + ); +} + +export default memo(GroupedPasswordList); diff --git a/src/components/HomeFab.tsx b/src/components/HomeFab.tsx new file mode 100644 index 00000000..cf590e40 --- /dev/null +++ b/src/components/HomeFab.tsx @@ -0,0 +1,85 @@ +import SpeedDialFab, { type SpeedDialAction } from "@/components/SpeedDialFab"; +import type { CategoryNoun } from "@/libs/category_actions"; +import type { HomeLayout } from "@/libs/home_layout"; +import { useIsSelecting } from "@/libs/selection_store"; +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; +import { router } from "expo-router"; +import { memo, useMemo } from "react"; +import { FAB } from "react-native-paper"; + +// Module level => stable identities (no FAB re-render / animation replay). +const renderPlus = (size: number, color: string) => ( + +); + +const renderFolderPlus = (size: number, color: string) => ( + +); + +const FolderPlusIcon = ({ size, color }: { size: number; color: string }) => + renderFolderPlus(size, color); + +function goToAddPassword() { + router.push("/save-password"); +} + +type HomeFabProps = { + layout: HomeLayout; + noun: CategoryNoun; + onCreateCategory: () => void; +}; + +/** + * Home screen FAB. + * + * - grouped layout: speed dial (New category / Add password) + * - folders layout: single "new folder" button (passwords are added from + * inside a folder) + * + * Memoized with stable props, so collapsing sections, searching or + * selecting never re-renders it. Hidden while multi-selecting. + */ +function HomeFab({ layout, noun, onCreateCategory }: HomeFabProps) { + const isSelectingNow = useIsSelecting(); + + const actions = useMemo( + () => [ + { + key: "category", + label: `New ${noun}`, + renderIcon: renderFolderPlus, + onPress: onCreateCategory, + }, + { + key: "password", + label: "Add password", + renderIcon: renderPlus, + onPress: goToAddPassword, + }, + ], + [noun, onCreateCategory], + ); + + if (isSelectingNow) { + return null; + } + + if (layout === "folders") { + return ( + + ); + } + + return ; +} + +export default memo(HomeFab); diff --git a/src/components/PasswordCardGrid.tsx b/src/components/PasswordCardGrid.tsx new file mode 100644 index 00000000..eb4c2301 --- /dev/null +++ b/src/components/PasswordCardGrid.tsx @@ -0,0 +1,34 @@ +import PasswordListItem from "@/components/PasswordListItem"; +import type { Password } from "@/libs/category_groups"; +import { memo } from "react"; +import { View } from "react-native"; + +// Module level, so the (memoized) cards always get the same style object. +const FILL = { flex: 1 } as const; + +/** + * Passwords laid out as a list (1 column) or grid (2 columns) inside a + * section. A wrapping row (not a nested list), so it can live inside the + * collapsible area of a section. + */ +function PasswordCardGrid({ + passwords, + numColumns, +}: { + passwords: Password[]; + numColumns: number; +}) { + const width = numColumns === 2 ? "50%" : "100%"; + + return ( + + {passwords.map((password) => ( + + + + ))} + + ); +} + +export default memo(PasswordCardGrid); diff --git a/src/components/PasswordListItem.tsx b/src/components/PasswordListItem.tsx index 8cf62280..f7748004 100644 --- a/src/components/PasswordListItem.tsx +++ b/src/components/PasswordListItem.tsx @@ -1,64 +1,116 @@ import PasswordItemCard from "@/components/PasswordItemCard"; -import { usePasswordRepository } from "@/contexts/RepositoryContext"; -import { useToast } from "@/contexts/ToastContext"; -import type { passwords } from "@/db/schema"; +import type { Password } from "@/libs/category_groups"; +import { + isSelecting, + toggleSelected, + useSelectionState, +} from "@/libs/selection_store"; +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; import { router } from "expo-router"; -import { Alert, Pressable, type StyleProp, type ViewStyle } from "react-native"; - -type PasswordRow = typeof passwords.$inferSelect; +import { memo, useCallback } from "react"; +import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native"; +import { useTheme } from "react-native-paper"; /** - * One tappable password card: tap = open details, long press = delete. - * Shared by the grouped home list and the single-category screen. + * One password card. + * + * - tap: open details (or toggle selection while selecting) + * - long press: start selecting (gallery style), delete / move are in the + * selection bar + * + * Memoized and subscribed to the selection store per id, so selecting one + * card re-renders only that card. */ -export default function PasswordListItem({ +function PasswordListItem({ item, style, }: { - item: PasswordRow; + item: Password; style?: StyleProp; }) { - const passwordRepository = usePasswordRepository(); - const { showToast } = useToast(); + const theme = useTheme(); + const selection = useSelectionState(item.id); + const isSelected = selection === "selected"; + + const handlePress = useCallback(() => { + if (isSelecting()) { + toggleSelected(item.id); + return; + } + + router.push({ + pathname: "/password-details", + params: { id: item.id }, + }); + }, [item.id]); - function confirmDelete() { - Alert.alert("Delete?", `${item.domain} : ${item.username}`, [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Delete", - onPress: async () => { - try { - await passwordRepository.delete(item.id); - } catch (error) { - console.error("Failed to delete password:", error); - showToast("Failed to delete; please try again", "error"); - } - }, - style: "destructive", - }, - ]); - } + const handleLongPress = useCallback(() => { + toggleSelected(item.id); + }, [item.id]); return ( - router.push({ - pathname: "/password-details", - params: { id: item.id }, - }) - } - onLongPress={confirmDelete} + onPress={handlePress} + onLongPress={handleLongPress} + accessibilityRole="button" + accessibilityHint="Long press to select" + accessibilityState={{ selected: isSelected }} > + + {selection !== "off" && ( + <> + + + + {isSelected && ( + + )} + + + )} ); } + +export default memo(PasswordListItem); diff --git a/src/components/SelectionBar.tsx b/src/components/SelectionBar.tsx new file mode 100644 index 00000000..70c7373b --- /dev/null +++ b/src/components/SelectionBar.tsx @@ -0,0 +1,168 @@ +import CategoryPickerSheet from "@/components/CategoryPickerSheet"; +import Text from "@/components/Text"; +import { usePasswordRepository } from "@/contexts/RepositoryContext"; +import { useToast } from "@/contexts/ToastContext"; +import { + clearSelection, + getSelectedIds, + selectMany, + useSelectionCount, +} from "@/libs/selection_store"; +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; +import { memo, useState } from "react"; +import { Alert } from "react-native"; +import { IconButton, Surface, useTheme } from "react-native-paper"; +import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated"; + +function passwordsLabel(count: number) { + return `${count} ${count === 1 ? "password" : "passwords"}`; +} + +/** + * Bottom bar shown while multi-selecting: close, count, select all, move, + * delete. Subscribes to the selection store itself, so the screen does not + * re-render when the selection changes. + * + * `allIds` = the passwords currently visible (what "select all" selects). + */ +function SelectionBar({ allIds }: { allIds: number[] }) { + const theme = useTheme(); + const passwordRepository = usePasswordRepository(); + const { showToast } = useToast(); + + const count = useSelectionCount(); + const [isMoving, setIsMoving] = useState(false); + + async function handleMove(categoryId: number | null, name: string) { + setIsMoving(false); + + const ids = getSelectedIds(); + + try { + await passwordRepository.moveToCategory(ids, categoryId); + clearSelection(); + showToast(`Moved ${passwordsLabel(ids.length)} to ${name}`); + } catch (err) { + console.error("Failed to move passwords:", err); + showToast("Failed to move; please try again", "error"); + } + } + + function handleDelete() { + const ids = getSelectedIds(); + + Alert.alert( + `Delete ${passwordsLabel(ids.length)}?`, + "This can't be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await passwordRepository.deleteMany(ids); + clearSelection(); + showToast(`Deleted ${passwordsLabel(ids.length)}`); + } catch (err) { + console.error("Failed to delete passwords:", err); + showToast("Failed to delete; please try again", "error"); + } + }, + }, + ], + ); + } + + if (count === 0) { + return null; + } + + return ( + <> + + + ( + + )} + onPress={clearSelection} + /> + + + {count} selected + + + ( + + )} + onPress={() => selectMany(allIds)} + /> + + ( + + )} + onPress={() => setIsMoving(true)} + /> + + ( + + )} + onPress={handleDelete} + /> + + + + {isMoving && ( + setIsMoving(false)} + /> + )} + + ); +} + +export default memo(SelectionBar); diff --git a/src/components/SpeedDialFab.tsx b/src/components/SpeedDialFab.tsx new file mode 100644 index 00000000..e3b4ac67 --- /dev/null +++ b/src/components/SpeedDialFab.tsx @@ -0,0 +1,155 @@ +import Text from "@/components/Text"; +import { + memo, + useCallback, + useEffect, + useState, + type ReactNode, +} from "react"; +import { BackHandler, Pressable, StyleSheet, View } from "react-native"; +import { FAB, Surface } from "react-native-paper"; +import Animated, { + FadeIn, + FadeInDown, + FadeOut, + FadeOutDown, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +export type SpeedDialAction = { + key: string; + label: string; + renderIcon: (size: number, color: string) => ReactNode; + onPress: () => void; +}; + +type SpeedDialFabProps = { + actions: SpeedDialAction[]; + /** Icon of the main button (rotates to an "x" while open). */ + renderIcon: (size: number, color: string) => ReactNode; + accessibilityLabel?: string; +}; + +/** + * Reusable expanding FAB ("speed dial"). + * + * Built on plain views, NOT Paper's `FAB.Group` (that one renders in a + * Portal, i.e. above the tab bar). Memoized: as long as `actions` is stable + * it never re-renders because of its parent. + */ +function SpeedDialFab({ + actions, + renderIcon, + accessibilityLabel = "Open actions", +}: SpeedDialFabProps) { + const [open, setOpen] = useState(false); + + const rotation = useSharedValue(0); + + useEffect(() => { + rotation.value = withTiming(open ? 1 : 0, { duration: 200 }); + }, [open, rotation]); + + // Android back button closes the menu first. + useEffect(() => { + if (!open) { + return; + } + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => { + setOpen(false); + return true; + }, + ); + + return () => subscription.remove(); + }, [open]); + + const iconStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value * 45}deg` }], + })); + + const close = useCallback(() => setOpen(false), []); + + const mainIcon = useCallback( + ({ size, color }: { size: number; color: string }) => ( + {renderIcon(size, color)} + ), + [iconStyle, renderIcon], + ); + + return ( + <> + {open && ( + + + + )} + + + {open && + actions.map((action, index) => ( + + + {action.label} + + + action.renderIcon(size, color)} + onPress={() => { + close(); + action.onPress(); + }} + /> + + ))} + + setOpen((value) => !value)} + /> + + + ); +} + +export default memo(SpeedDialFab); diff --git a/src/libs/category_actions.tsx b/src/libs/category_actions.tsx new file mode 100644 index 00000000..dffadd69 --- /dev/null +++ b/src/libs/category_actions.tsx @@ -0,0 +1,178 @@ +import CategoryNameDialog from "@/components/CategoryNameDialog"; +import { + useCategoryRepository, + usePasswordRepository, +} from "@/contexts/RepositoryContext"; +import { useToast } from "@/contexts/ToastContext"; +import { + UNCATEGORIZED_LABEL, + type DeleteCategoryPasswordsMode, +} from "@/repositories/CategoryRepository"; +import { useCallback, useState } from "react"; +import { Alert } from "react-native"; + +/** The UI calls the same thing "category" or "folder" depending on layout. */ +export type CategoryNoun = "category" | "folder"; + +export type CategoryRef = { id: number; name: string }; + +type DialogState = + | { mode: "create" } + | { mode: "rename"; category: CategoryRef } + | null; + +function passwordsLabel(count: number) { + return `${count} ${count === 1 ? "password" : "passwords"}`; +} + +/** + * Create / rename / delete flows for categories, shared by both layouts. + * + * Returns stable callbacks plus `dialogElement`, which the screen renders + * once (it is `null` unless a name dialog is open). + */ +export function useCategoryActions(noun: CategoryNoun) { + const categoryRepository = useCategoryRepository(); + const passwordRepository = usePasswordRepository(); + const { showToast } = useToast(); + + const [dialog, setDialog] = useState(null); + + const Noun = noun === "folder" ? "Folder" : "Category"; + + const runDelete = useCallback( + async ( + category: CategoryRef, + mode: DeleteCategoryPasswordsMode, + count: number, + onDeleted?: () => void, + ) => { + try { + await categoryRepository.delete(category.id, mode); + onDeleted?.(); + + if (count === 0) { + showToast(`${Noun} deleted`); + } else if (mode === "delete") { + showToast(`${Noun} and ${passwordsLabel(count)} deleted`); + } else { + showToast( + `${Noun} deleted, ${passwordsLabel(count)} moved to ${UNCATEGORIZED_LABEL}`, + ); + } + } catch (err) { + console.error(`Failed to delete ${noun}:`, err); + showToast(`Failed to delete ${noun}; please try again`, "error"); + } + }, + [categoryRepository, showToast, Noun, noun], + ); + + const confirmDelete = useCallback( + async (category: CategoryRef, onDeleted?: () => void) => { + let count = 0; + + try { + count = await passwordRepository.countByCategory(category.id); + } catch (err) { + console.error("Failed to count passwords:", err); + showToast(`Failed to delete ${noun}; please try again`, "error"); + return; + } + + if (count === 0) { + Alert.alert(`Delete ${noun}?`, `"${category.name}"`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => runDelete(category, "uncategorize", 0, onDeleted), + }, + ]); + + return; + } + + Alert.alert( + `Delete "${category.name}"?`, + `This ${noun} has ${passwordsLabel(count)}. What should happen to them?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete passwords too", + style: "destructive", + onPress: () => + // Destructive path needs its own, separate confirmation. + Alert.alert( + `Delete ${passwordsLabel(count)}?`, + `"${category.name}" and its ${passwordsLabel(count)} will be deleted permanently. This can't be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => runDelete(category, "delete", count, onDeleted), + }, + ], + ), + }, + { + // The safe default. + text: `Move to ${UNCATEGORIZED_LABEL}`, + onPress: () => runDelete(category, "uncategorize", count, onDeleted), + }, + ], + ); + }, + [passwordRepository, runDelete, showToast, noun], + ); + + const openCreate = useCallback(() => setDialog({ mode: "create" }), []); + + /** Rename / Delete menu for one category. */ + const openActions = useCallback( + (category: CategoryRef, onDeleted?: () => void) => { + Alert.alert(category.name, undefined, [ + { text: "Cancel", style: "cancel" }, + { + text: "Rename", + onPress: () => setDialog({ mode: "rename", category }), + }, + { + text: "Delete", + style: "destructive", + onPress: () => confirmDelete(category, onDeleted), + }, + ]); + }, + [confirmDelete], + ); + + const dialogElement = + dialog?.mode === "create" ? ( + { + await categoryRepository.create(name); + setDialog(null); + showToast(`${Noun} created`); + }} + onCancel={() => setDialog(null)} + /> + ) : dialog?.mode === "rename" ? ( + { + await categoryRepository.rename(dialog.category.id, name); + setDialog(null); + showToast(`${Noun} renamed`); + }} + onCancel={() => setDialog(null)} + /> + ) : null; + + return { openCreate, openActions, dialogElement }; +} diff --git a/src/libs/category_groups.ts b/src/libs/category_groups.ts new file mode 100644 index 00000000..75d4454f --- /dev/null +++ b/src/libs/category_groups.ts @@ -0,0 +1,164 @@ +import { + useCategoryRepository, + usePasswordRepository, +} from "@/contexts/RepositoryContext"; +import type { passwords } from "@/db/schema"; +import { UNCATEGORIZED_LABEL } from "@/repositories/CategoryRepository"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; +import { useMemo } from "react"; + +export type Password = typeof passwords.$inferSelect; + +export const UNCATEGORIZED_KEY = "uncategorized"; + +/** One collapsible section of the grouped layout. */ +export type CategoryGroup = { + key: string; + /** `null` = Uncategorized */ + categoryId: number | null; + name: string; + count: number; + passwords: Password[]; +}; + +/** One folder of the folders layout (count = ALL passwords of it). */ +export type FolderData = { + key: string; + categoryId: number | null; + name: string; + count: number; +}; + +/** + * Single source of truth for BOTH home layouts: loads passwords + categories + * (live) and derives sections, folders and counts, so the two layouts never + * disagree. + * + * A password whose category no longer exists is treated as Uncategorized, so + * it can never silently disappear. + */ +export function useCategoryGroups(query: string, refreshKey = 0) { + const passwordRepository = usePasswordRepository(); + const categoryRepository = useCategoryRepository(); + + const { data: passwordList = [] } = useLiveQuery( + passwordRepository.observeAll(), + [refreshKey], + ); + + const { data: categoryList = [] } = useLiveQuery( + categoryRepository.observeAll(), + [], + ); + + // xxxxx folders + counts (never depends on the search query) xxxxx + const { folders, resolveCategoryId } = useMemo(() => { + const nameById = new Map(categoryList.map((c) => [c.id, c.name])); + + const resolve = (password: Password): number | null => + password.categoryId !== null && nameById.has(password.categoryId) + ? password.categoryId + : null; + + const counts = new Map(); + + for (const password of passwordList) { + const id = resolve(password); + counts.set(id, (counts.get(id) ?? 0) + 1); + } + + const folderList: FolderData[] = categoryList.map((category) => ({ + key: String(category.id), + categoryId: category.id, + name: category.name, + count: counts.get(category.id) ?? 0, + })); + + // Uncategorized folder is always there, so there is always a place to + // add a password, even before any category exists. + folderList.push({ + key: UNCATEGORIZED_KEY, + categoryId: null, + name: UNCATEGORIZED_LABEL, + count: counts.get(null) ?? 0, + }); + + return { folders: folderList, resolveCategoryId: resolve }; + }, [passwordList, categoryList]); + + // xxxxx sections + visible ids (depends on the search query) xxxxx + const { groups, visibleIds } = useMemo(() => { + const nameById = new Map(categoryList.map((c) => [c.id, c.name])); + + const filtered = query + ? passwordList.filter((password) => { + const categoryName = ( + (password.categoryId !== null + ? nameById.get(password.categoryId) + : undefined) ?? UNCATEGORIZED_LABEL + ).toLowerCase(); + + return ( + (password.domain?.toLowerCase() ?? "").includes(query) || + (password.username?.toLowerCase() ?? "").includes(query) || + (password.url?.toLowerCase() ?? "").includes(query) || + (password.notes?.toLowerCase() ?? "").includes(query) || + categoryName.includes(query) + ); + }) + : passwordList; + + const byCategory = new Map(); + + for (const password of filtered) { + const id = resolveCategoryId(password); + const bucket = byCategory.get(id); + + if (bucket) { + bucket.push(password); + } else { + byCategory.set(id, [password]); + } + } + + const groupList: CategoryGroup[] = []; + + for (const category of categoryList) { + const items = byCategory.get(category.id) ?? []; + + // Empty categories are listed too (so a new category is visible), + // but not while searching. + if (items.length > 0 || !query) { + groupList.push({ + key: String(category.id), + categoryId: category.id, + name: category.name, + count: items.length, + passwords: items, + }); + } + } + + const uncategorized = byCategory.get(null) ?? []; + + if (uncategorized.length > 0) { + groupList.push({ + key: UNCATEGORIZED_KEY, + categoryId: null, + name: UNCATEGORIZED_LABEL, + count: uncategorized.length, + passwords: uncategorized, + }); + } + + return { groups: groupList, visibleIds: filtered.map((p) => p.id) }; + }, [passwordList, categoryList, query, resolveCategoryId]); + + return { + groups, + folders, + visibleIds, + hasPasswords: passwordList.length > 0, + hasCategories: categoryList.length > 0, + }; +} diff --git a/src/libs/collapse_store.ts b/src/libs/collapse_store.ts new file mode 100644 index 00000000..efd72c38 --- /dev/null +++ b/src/libs/collapse_store.ts @@ -0,0 +1,34 @@ +import { useSyncExternalStore } from "react"; + +/** + * In-memory collapsed/expanded state of the home sections. + * + * It lives OUTSIDE React state on purpose: toggling one section re-renders + * only that section, not the whole screen (and not the FAB). + * Reset on app restart (all sections start expanded). + */ +const collapsed = new Set(); +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void) { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +export function toggleCollapsed(key: string) { + if (collapsed.has(key)) { + collapsed.delete(key); + } else { + collapsed.add(key); + } + + listeners.forEach((listener) => listener()); +} + +/** Re-renders only when the collapsed state of THIS key changes. */ +export function useIsCollapsed(key: string): boolean { + return useSyncExternalStore(subscribe, () => collapsed.has(key)); +} diff --git a/src/libs/home_layout.ts b/src/libs/home_layout.ts new file mode 100644 index 00000000..c1eacd4a --- /dev/null +++ b/src/libs/home_layout.ts @@ -0,0 +1,47 @@ +import AsyncStorage from "expo-sqlite/kv-store"; +import { useSyncExternalStore } from "react"; + +/** + * How passwords are organised on the home screen. + * + * - "grouped": collapsible sections, one per category + * - "folders": a grid of folders, tap one to open its passwords + * + * Both layouts show the same flat categories, only the presentation differs. + */ +export type HomeLayout = "grouped" | "folders"; + +const HOME_LAYOUT_KEY = "feat_home_layout"; + +let current: HomeLayout | undefined; +const listeners = new Set<() => void>(); + +export function getHomeLayout(): HomeLayout { + if (current === undefined) { + current = + AsyncStorage.getItemSync(HOME_LAYOUT_KEY) === "folders" + ? "folders" + : "grouped"; // default + } + + return current; +} + +export function setHomeLayout(layout: HomeLayout) { + current = layout; + AsyncStorage.setItemAsync(HOME_LAYOUT_KEY, layout); + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +/** Reactive: components re-render when the setting changes. */ +export function useHomeLayout(): HomeLayout { + return useSyncExternalStore(subscribe, getHomeLayout); +} diff --git a/src/libs/selection_store.ts b/src/libs/selection_store.ts new file mode 100644 index 00000000..47514ec6 --- /dev/null +++ b/src/libs/selection_store.ts @@ -0,0 +1,98 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { BackHandler } from "react-native"; + +/** + * Multi-select (like a gallery app): long press a password to start, + * tap to add/remove, then move or delete the selection. + * + * Lives outside React state so selecting a card re-renders only that card + * (plus the selection bar), never the whole list. + */ +let selected = new Set(); +const listeners = new Set<() => void>(); + +function emit() { + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +export function toggleSelected(id: number) { + const next = new Set(selected); + + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + + selected = next; + emit(); +} + +export function selectMany(ids: number[]) { + selected = new Set(ids); + emit(); +} + +export function clearSelection() { + if (selected.size === 0) { + return; + } + + selected = new Set(); + emit(); +} + +export function getSelectedIds(): number[] { + return Array.from(selected); +} + +export function isSelecting(): boolean { + return selected.size > 0; +} + +export type SelectionState = "off" | "unselected" | "selected"; + +/** Per-card state, a card only re-renders when ITS state changes. */ +export function useSelectionState(id: number): SelectionState { + return useSyncExternalStore(subscribe, () => + selected.size === 0 ? "off" : selected.has(id) ? "selected" : "unselected", + ); +} + +/** Flips only when selection mode starts / ends. */ +export function useIsSelecting(): boolean { + return useSyncExternalStore(subscribe, () => selected.size > 0); +} + +export function useSelectionCount(): number { + return useSyncExternalStore(subscribe, () => selected.size); +} + +/** Android back button leaves selection mode first. */ +export function useSelectionBackHandler() { + const active = useIsSelecting(); + + useEffect(() => { + if (!active) { + return; + } + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => { + clearSelection(); + return true; + }, + ); + + return () => subscription.remove(); + }, [active]); +} diff --git a/src/repositories/CategoryRepository.ts b/src/repositories/CategoryRepository.ts index cd351523..d1b0f4c5 100644 --- a/src/repositories/CategoryRepository.ts +++ b/src/repositories/CategoryRepository.ts @@ -4,6 +4,13 @@ import { and, asc, eq, ne, sql } from "drizzle-orm"; export const CATEGORY_NAME_MAX_LENGTH = 40; +/** + * Display name of the virtual group of passwords WITHOUT a category + * (`category_id IS NULL`), the "leftovers", not "all passwords". + * Change it here and it changes everywhere. + */ +export const UNCATEGORIZED_LABEL = "Unsorted"; + /** * Thrown for invalid user input (empty / too long / duplicate name). * The message is safe to show to the user in a toast. @@ -117,7 +124,7 @@ export class CategoryRepository { * (no extra query, no N+1). */ static countPasswordsByCategory( - passwordList: ReadonlyArray<{ categoryId: number | null }>, + passwordList: readonly { categoryId: number | null }[], ): Map { const counts = new Map(); @@ -143,10 +150,15 @@ export class CategoryRepository { ); } - // "Uncategorized" is reserved for the virtual NULL group. - if (cleanName.toLowerCase() === "uncategorized") { + // The name of the virtual NULL group is reserved. + const lowerName = cleanName.toLowerCase(); + + if ( + lowerName === UNCATEGORIZED_LABEL.toLowerCase() || + lowerName === "uncategorized" + ) { throw new CategoryValidationError( - '"Uncategorized" is reserved, please pick another name.', + `"${cleanName}" is reserved, please pick another name.`, ); } diff --git a/src/repositories/PasswordRepository.ts b/src/repositories/PasswordRepository.ts index 8102e451..05b6a47c 100644 --- a/src/repositories/PasswordRepository.ts +++ b/src/repositories/PasswordRepository.ts @@ -1,6 +1,19 @@ import type { DrizzleDatabase } from "@/db/provider"; import { passwords } from "@/db/schema"; -import { eq, isNull, sql } from "drizzle-orm"; +import { eq, inArray, isNull, sql } from "drizzle-orm"; + +/** SQLite limits the number of bound variables, so big batches are chunked. */ +const BATCH_SIZE = 500; + +function chunk(items: T[], size: number): T[][] { + const chunks: T[][] = []; + + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + + return chunks; +} export interface CreatePasswordInput { domain: string; @@ -118,4 +131,47 @@ export class PasswordRepository { return deletedPassword; } + + /** Number of passwords in ONE category. */ + async countByCategory(categoryId: number) { + const [row] = await this.db + .select({ count: sql`count(*)` }) + .from(passwords) + .where(eq(passwords.categoryId, categoryId)); + + return Number(row?.count ?? 0); + } + + /** + * Moves many passwords to a category (`null` = Uncategorized) in one + * transaction. Only `category_id` changes, `updated_at` is left alone + * because the password itself did not change. + */ + async moveToCategory(ids: number[], categoryId: number | null) { + if (ids.length === 0) { + return; + } + + this.db.transaction((tx) => { + for (const idChunk of chunk(ids, BATCH_SIZE)) { + tx.update(passwords) + .set({ categoryId }) + .where(inArray(passwords.id, idChunk)) + .run(); + } + }); + } + + /** Deletes many passwords in one transaction. */ + async deleteMany(ids: number[]) { + if (ids.length === 0) { + return; + } + + this.db.transaction((tx) => { + for (const idChunk of chunk(ids, BATCH_SIZE)) { + tx.delete(passwords).where(inArray(passwords.id, idChunk)).run(); + } + }); + } }