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-lock.json b/package-lock.json index 18197059..6c702c01 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", @@ -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" }, @@ -3138,9 +3137,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 +7263,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 +7282,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 +7404,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 +7598,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 +7628,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 +7642,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 +7680,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": "*", @@ -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 d49246be..c4ff0d25 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", @@ -24,7 +25,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 +34,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", @@ -47,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/app/(tabs)/(password-manager)/_layout.tsx b/src/app/(tabs)/(password-manager)/_layout.tsx index 5c13cdb7..c4a26b1a 100644 --- a/src/app/(tabs)/(password-manager)/_layout.tsx +++ b/src/app/(tabs)/(password-manager)/_layout.tsx @@ -10,6 +10,11 @@ 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 { openActions, dialogElement } = useCategoryActions("folder"); + + useFocusEffect(useCallback(() => clearSelection, [])); + useSelectionBackHandler(); + + const title = + categoryId === null + ? UNCATEGORIZED_LABEL + : (categoryList.find((category) => category.id === categoryId)?.name ?? + "Folder"); + + const toggleGrid = useCallback(() => setForceGrid((g) => !g), []); + + const visibleIds = useMemo( + () => passwordList.map((password) => password.id), + [passwordList], + ); + + return ( + <> + ( + + + + {/* 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(), + ) + } + /> + )} + + ), + }} + /> + + item.id.toString()} + contentContainerStyle={{ + padding: 20, + paddingBottom: 110, // room for the FAB / selection bar + }} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + No passwords in this folder. + + + + + } + renderItem={({ item }) => } + /> + + + + + + {dialogElement} + + ); +} diff --git a/src/app/(tabs)/(password-manager)/index.tsx b/src/app/(tabs)/(password-manager)/index.tsx index a6f28a35..a19f60f8 100644 --- a/src/app/(tabs)/(password-manager)/index.tsx +++ b/src/app/(tabs)/(password-manager)/index.tsx @@ -1,15 +1,23 @@ -import PasswordItemCard from "@/components/PasswordItemCard"; -import Text from "@/components/Text"; -import { usePasswordRepository } from "@/contexts/RepositoryContext"; -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 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 { + 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; @@ -19,77 +27,55 @@ export default function LoadPasswordScreen() { const theme = useTheme(); - const passwordRepository = usePasswordRepository(); + const query = searchQuery.trim().toLowerCase(); + const isSearching = query !== ""; + + // Search finds passwords, so while searching always show the matches. + const showFolders = layout === "folders" && !isSearching; - const { data: passwordList = [] } = useLiveQuery( - passwordRepository.observeAll(), - [refreshKey], + const { groups, folders, visibleIds, hasCategories } = useCategoryGroups( + query, + refreshKey, ); - const filteredPasswords = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); - - 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() ?? ""; - - return ( - domain.includes(query) || - username.includes(query) || - url.includes(query) || - notes.includes(query) - ); - }); - }, [passwordList, searchQuery]); + const { openCreate, openActions, dialogElement } = useCategoryActions(noun); + + // Leaving the screen ends multi-select; Android back leaves it first. + useFocusEffect(useCallback(() => clearSelection, [])); + useSelectionBackHandler(); - async function onRefresh() { + const toggleGrid = useCallback(() => setForceGrid((g) => !g), []); + + const onRefresh = useCallback(async () => { setRefreshing(true); setRefreshKey((k) => k + 1); 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", + const onOpenFolder = useCallback((categoryId: number | null) => { + router.push({ + pathname: "/category-passwords", + params: { + categoryId: categoryId === null ? UNCATEGORIZED_KEY : categoryId, }, - ]); - } + }); + }, []); return ( <> ( - ( - - )} - onPress={() => { - setForceGrid((g) => !g); - }} - /> - ), + // Always set (never omitted): the header keeps old options + // otherwise. Folders have no list/grid switch, so render nothing. + headerRight: (props) => + showFolders ? null : ( + + ), }} > Password Manager @@ -111,68 +97,34 @@ export default function LoadPasswordScreen() { /> - item.id.toString()} - refreshing={refreshing} - onRefresh={onRefresh} - contentContainerStyle={{ - padding: 20, - }} - showsVerticalScrollIndicator={false} - ListEmptyComponent={ - - {searchQuery.trim() ? "No passwords found!!" : "No data!!"} - - } - renderItem={({ item }) => ( - - router.push({ - pathname: "/password-details", - params: { id: item.id }, - }) - } - onLongPress={() => deletePassword(item)} - > - - - )} - /> - - ( - + ) : ( + <> + - )} - onPress={() => { - router.push("/save-password"); - }} - /> + + + + )} + + + + {dialogElement} ); } 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..0e32456b 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,19 @@ 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) + // `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); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + }); + const passwordRepository = usePasswordRepository(); async function handleSavePassword() { @@ -32,6 +46,7 @@ export default function SavePasswordScreen() { password, notes, url, + categoryId, }); showToast("Password saved successfully"); @@ -80,6 +95,10 @@ 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 new file mode 100644 index 00000000..20fac2c7 --- /dev/null +++ b/src/components/CategoryField.tsx @@ -0,0 +1,85 @@ +import CategoryPickerSheet from "@/components/CategoryPickerSheet"; +import FormTextField from "@/components/FormTextField"; +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 { Pressable, View } from "react-native"; +import { TextInput } from "react-native-paper"; + +type CategoryFieldProps = { + /** Selected category id, `null` = Uncategorized. */ + value: number | null; + onChange: (categoryId: number | null) => void; + editable?: boolean; +}; + +/** + * 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, + onChange, + editable = true, +}: CategoryFieldProps) { + const categoryRepository = useCategoryRepository(); + + const { data: categoryList = [] } = useLiveQuery( + categoryRepository.observeAll(), + [], + ); + + const [isOpen, setIsOpen] = useState(false); + + const selectedName = + categoryList.find((category) => category.id === value)?.name ?? + UNCATEGORIZED_LABEL; + + return ( + <> + setIsOpen(true)} + accessibilityRole="button" + accessibilityLabel={`Category: ${selectedName}`} + accessibilityHint={editable ? "Opens the category list" : undefined} + > + + ( + + )} + /> + ) : undefined + } + /> + + + + {isOpen && ( + { + onChange(categoryId); + setIsOpen(false); + }} + onClose={() => setIsOpen(false)} + /> + )} + + ); +} 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/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/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 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]); + + const handleLongPress = useCallback(() => { + toggleSelected(item.id); + }, [item.id]); + + return ( + + + + {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/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", 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/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 new file mode 100644 index 00000000..d1b0f4c5 --- /dev/null +++ b/src/repositories/CategoryRepository.ts @@ -0,0 +1,185 @@ +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; + +/** + * 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. + */ +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: readonly { 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.`, + ); + } + + // The name of the virtual NULL group is reserved. + const lowerName = cleanName.toLowerCase(); + + if ( + lowerName === UNCATEGORIZED_LABEL.toLowerCase() || + lowerName === "uncategorized" + ) { + throw new CategoryValidationError( + `"${cleanName}" 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..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, 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; @@ -8,6 +21,8 @@ export interface CreatePasswordInput { password: string; notes?: string | null; url?: string | null; + /** `null` / omitted = Uncategorized. */ + categoryId?: number | null; } export interface UpdatePasswordInput { @@ -16,6 +31,8 @@ export interface UpdatePasswordInput { password?: string; notes?: string | null; url?: string | null; + /** `null` = move to Uncategorized. */ + categoryId?: number | null; } export class PasswordRepository { @@ -31,6 +48,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(); } @@ -91,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(); + } + }); + } }