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();
+ }
+ });
+ }
}