From 33aa7a8039c28cca33c96d6676478e991fdc3686 Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Mon, 31 Aug 2026 17:43:51 +0530 Subject: [PATCH 1/4] feat: improve details screen design with fabs Signed-off-by: Jeel Dobariya --- .../(password-manager)/password-details.tsx | 278 +++++++++++++----- src/app/(tabs)/password-generator.tsx | 5 +- 2 files changed, 208 insertions(+), 75 deletions(-) diff --git a/src/app/(tabs)/(password-manager)/password-details.tsx b/src/app/(tabs)/(password-manager)/password-details.tsx index b47bbc57..47e1463a 100644 --- a/src/app/(tabs)/(password-manager)/password-details.tsx +++ b/src/app/(tabs)/(password-manager)/password-details.tsx @@ -1,17 +1,21 @@ import FormTextField from "@/components/FormTextField"; import SecureTextField from "@/components/SecureTextField"; + import { passwords } from "@/db/schema"; import { getScreenShotSecureScreen } from "@/libs/screenshot_prevention"; import formatDate from "@/utils/formating"; + import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/expo-sqlite"; import { Href, router, useLocalSearchParams } from "expo-router"; import { usePreventRemove } from "expo-router/react-navigation"; import { useSQLiteContext } from "expo-sqlite"; -import { useEffect, useState } from "react"; -import { Keyboard, ScrollView, View } from "react-native"; -import { Button } from "react-native-paper"; + +import { useEffect, useRef, useState } from "react"; +import { Animated, Keyboard, ScrollView, TextInput, View } from "react-native"; + +import { Button, FAB, Text } from "react-native-paper"; import { SafeAreaView } from "react-native-safe-area-context"; export default function PasswordDetailsScreen() { @@ -29,8 +33,56 @@ export default function PasswordDetailsScreen() { const [isEditing, setIsEditing] = useState(false); + /* + * Toast state + */ + const [toastMessage, setToastMessage] = useState(""); + + const toastOpacity = useRef(new Animated.Value(0)).current; + const toastTimeout = useRef | null>(null); + getScreenShotSecureScreen(); + /** + * Remove focus from whichever TextInput is currently focused + * and dismiss the keyboard. + * + * This is important because Keyboard.dismiss() alone can leave + * the native TextInput focused, which can leave the focus outline + * visible after leaving edit mode. + */ + function unfocusAllFields() { + TextInput.State.currentlyFocusedInput()?.blur(); + Keyboard.dismiss(); + } + + /** + * Show a small cross-platform feedback message. + */ + function showToast(message: string) { + if (toastTimeout.current) { + clearTimeout(toastTimeout.current); + } + + setToastMessage(message); + + Animated.timing(toastOpacity, { + toValue: 1, + duration: 150, + useNativeDriver: true, + }).start(); + + toastTimeout.current = setTimeout(() => { + Animated.timing(toastOpacity, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }).start(() => { + setToastMessage(""); + }); + }, 2200); + } + async function loadAndRefreshPassword() { const result = await drizzleDb .select() @@ -50,6 +102,14 @@ export default function PasswordDetailsScreen() { } async function updatePassword() { + /* + * Blur BEFORE changing isEditing. + * + * This prevents the TextInput from remaining focused when + * it becomes read-only. + */ + unfocusAllFields(); + await drizzleDb .update(passwords) .set({ @@ -62,26 +122,58 @@ export default function PasswordDetailsScreen() { }) .where(eq(passwords.id, Number(id))); - Keyboard.dismiss(); setIsEditing(false); - router.back(); + + await loadAndRefreshPassword(); + + showToast("Password saved successfully"); + } + + function handleCancel() { + /* + * Remove focus before switching the inputs to read-only. + */ + unfocusAllFields(); + + setIsEditing(false); + + /* + * Restore the values from the database. + */ + loadAndRefreshPassword(); + + showToast("Changes discarded"); + } + + function handleFabPress() { + if (isEditing) { + updatePassword(); + } else { + setIsEditing(true); + } } usePreventRemove(isEditing, () => { - Keyboard.dismiss(); + /* + * If the user uses the Android back gesture/button while editing, + * make sure the active TextInput loses focus first. + */ + unfocusAllFields(); + setIsEditing(false); + loadAndRefreshPassword(); }); useEffect(() => { loadAndRefreshPassword(); - }, []); - function handleCancel() { - Keyboard.dismiss(); - setIsEditing(false); - loadAndRefreshPassword(); - } + return () => { + if (toastTimeout.current) { + clearTimeout(toastTimeout.current); + } + }; + }, []); return ( @@ -89,6 +181,7 @@ export default function PasswordDetailsScreen() { contentContainerStyle={{ margin: 20, gap: 24, + paddingBottom: 100, }} keyboardShouldPersistTaps="handled" > @@ -146,69 +239,110 @@ export default function PasswordDetailsScreen() { gap: 4, }} > - {!isEditing ? ( - <> - - - - - ) : ( - <> - - - - + {!isEditing && ( + + )} + + {isEditing && ( + )} + + ( + + )} + onPress={handleFabPress} + /> + + {/* + * Cross-platform feedback toast + */} + {toastMessage !== "" && ( + + + + + + {toastMessage} + + + + )} ); } diff --git a/src/app/(tabs)/password-generator.tsx b/src/app/(tabs)/password-generator.tsx index 18449bbe..6608fd68 100644 --- a/src/app/(tabs)/password-generator.tsx +++ b/src/app/(tabs)/password-generator.tsx @@ -312,9 +312,8 @@ function StrengthIndicator({ score }: { score: number }) { - Weak - - Strong + Weak + Strong ); From 46d9abb52fe7ae5bc67168d67dc0871a7eae65a2 Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Mon, 31 Aug 2026 19:14:46 +0530 Subject: [PATCH 2/4] fix: textfield focus in details screen on cancel update Signed-off-by: Jeel Dobariya --- .../(password-manager)/password-details.tsx | 12 +---- src/components/FormTextField.tsx | 46 +++++++++++++++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/app/(tabs)/(password-manager)/password-details.tsx b/src/app/(tabs)/(password-manager)/password-details.tsx index 47e1463a..5397064d 100644 --- a/src/app/(tabs)/(password-manager)/password-details.tsx +++ b/src/app/(tabs)/(password-manager)/password-details.tsx @@ -153,17 +153,7 @@ export default function PasswordDetailsScreen() { } } - usePreventRemove(isEditing, () => { - /* - * If the user uses the Android back gesture/button while editing, - * make sure the active TextInput loses focus first. - */ - unfocusAllFields(); - - setIsEditing(false); - - loadAndRefreshPassword(); - }); + usePreventRemove(isEditing, () => handleCancel()); useEffect(() => { loadAndRefreshPassword(); diff --git a/src/components/FormTextField.tsx b/src/components/FormTextField.tsx index 4bb6fe02..4ac14934 100644 --- a/src/components/FormTextField.tsx +++ b/src/components/FormTextField.tsx @@ -1,7 +1,47 @@ +import { forwardRef, useEffect, useRef, useState } from "react"; +import { TextInput as RNTextInput } from "react-native"; import { TextInput, TextInputProps } from "react-native-paper"; type Props = TextInputProps; -export default function FormTextField({ ...props }: Props) { - return ; -} +const FormTextField = forwardRef( + ({ editable = true, ...props }, ref) => { + const inputRef = useRef(null); + + const [internalEditable, setInternalEditable] = useState(editable); + + useEffect(() => { + if (editable === internalEditable) { + return; + } + + if (!editable) { + inputRef.current?.blur(); + } + + setInternalEditable(editable); + }, [editable, internalEditable]); + + return ( + { + inputRef.current = instance; + + if (typeof ref === "function") { + ref(instance); + } else if (ref) { + ref.current = instance; + } + }} + mode="outlined" + style={{ fontSize: 12 }} + editable={internalEditable} + {...props} + /> + ); + }, +); + +FormTextField.displayName = "FormTextField"; + +export default FormTextField; From b60f32e37dfd2134774533737550497d6522e004 Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Mon, 31 Aug 2026 19:15:02 +0530 Subject: [PATCH 3/4] refactor: toast system Signed-off-by: Jeel Dobariya --- .../(password-manager)/password-details.tsx | 102 +--------------- src/app/_layout.tsx | 5 +- src/components/ToastMessage.tsx | 109 ++++++++++++++++++ src/contexts/ToastContext.tsx | 62 ++++++++++ 4 files changed, 180 insertions(+), 98 deletions(-) create mode 100644 src/components/ToastMessage.tsx create mode 100644 src/contexts/ToastContext.tsx diff --git a/src/app/(tabs)/(password-manager)/password-details.tsx b/src/app/(tabs)/(password-manager)/password-details.tsx index 5397064d..d86ba751 100644 --- a/src/app/(tabs)/(password-manager)/password-details.tsx +++ b/src/app/(tabs)/(password-manager)/password-details.tsx @@ -1,5 +1,6 @@ import FormTextField from "@/components/FormTextField"; import SecureTextField from "@/components/SecureTextField"; +import { useToast } from "@/contexts/ToastContext"; import { passwords } from "@/db/schema"; import { getScreenShotSecureScreen } from "@/libs/screenshot_prevention"; @@ -12,10 +13,10 @@ import { Href, router, useLocalSearchParams } from "expo-router"; import { usePreventRemove } from "expo-router/react-navigation"; import { useSQLiteContext } from "expo-sqlite"; -import { useEffect, useRef, useState } from "react"; -import { Animated, Keyboard, ScrollView, TextInput, View } from "react-native"; +import { useEffect, useState } from "react"; +import { Keyboard, ScrollView, TextInput, View } from "react-native"; -import { Button, FAB, Text } from "react-native-paper"; +import { Button, FAB } from "react-native-paper"; import { SafeAreaView } from "react-native-safe-area-context"; export default function PasswordDetailsScreen() { @@ -33,13 +34,7 @@ export default function PasswordDetailsScreen() { const [isEditing, setIsEditing] = useState(false); - /* - * Toast state - */ - const [toastMessage, setToastMessage] = useState(""); - - const toastOpacity = useRef(new Animated.Value(0)).current; - const toastTimeout = useRef | null>(null); + const { showToast } = useToast(); getScreenShotSecureScreen(); @@ -56,33 +51,6 @@ export default function PasswordDetailsScreen() { Keyboard.dismiss(); } - /** - * Show a small cross-platform feedback message. - */ - function showToast(message: string) { - if (toastTimeout.current) { - clearTimeout(toastTimeout.current); - } - - setToastMessage(message); - - Animated.timing(toastOpacity, { - toValue: 1, - duration: 150, - useNativeDriver: true, - }).start(); - - toastTimeout.current = setTimeout(() => { - Animated.timing(toastOpacity, { - toValue: 0, - duration: 200, - useNativeDriver: true, - }).start(() => { - setToastMessage(""); - }); - }, 2200); - } - async function loadAndRefreshPassword() { const result = await drizzleDb .select() @@ -157,12 +125,6 @@ export default function PasswordDetailsScreen() { useEffect(() => { loadAndRefreshPassword(); - - return () => { - if (toastTimeout.current) { - clearTimeout(toastTimeout.current); - } - }; }, []); return ( @@ -279,60 +241,6 @@ export default function PasswordDetailsScreen() { )} onPress={handleFabPress} /> - - {/* - * Cross-platform feedback toast - */} - {toastMessage !== "" && ( - - - - - - {toastMessage} - - - - )} ); } diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 076c736d..8a8e4095 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,4 +1,5 @@ import ScreenHeading from "@/components/ScreenHeading"; +import { ToastProvider } from "@/contexts/ToastContext"; import DatabaseProvider from "@/db/provider"; import { isBiometricsAuthEnabled, @@ -97,7 +98,9 @@ function AppContent() { return ( - + + + ); diff --git a/src/components/ToastMessage.tsx b/src/components/ToastMessage.tsx new file mode 100644 index 00000000..2be9dd0c --- /dev/null +++ b/src/components/ToastMessage.tsx @@ -0,0 +1,109 @@ +// components/ToastMessage.tsx + +import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; +import { useEffect, useRef } from "react"; +import { Animated, View } from "react-native"; +import { Text } from "react-native-paper"; + +type ToastMessageProps = { + message: string; + visible: boolean; + duration?: number; + onHide: () => void; +}; + +export default function ToastMessage({ + message, + visible, + duration = 2200, + onHide, +}: ToastMessageProps) { + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!visible) { + Animated.timing(opacity, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }).start(); + + return; + } + + Animated.timing(opacity, { + toValue: 1, + duration: 150, + useNativeDriver: true, + }).start(); + + const timeout = setTimeout(() => { + Animated.timing(opacity, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }).start(() => { + onHide(); + }); + }, duration); + + return () => { + clearTimeout(timeout); + }; + }, [visible, duration, onHide, opacity]); + + if (!visible) { + return null; + } + + return ( + + + + + + {message} + + + + ); +} diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 00000000..86929464 --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,62 @@ +// contexts/ToastContext.tsx + +import ToastMessage from "@/components/ToastMessage"; + +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +type ToastContextValue = { + showToast: (message: string, duration?: number) => void; +}; + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: PropsWithChildren) { + const [message, setMessage] = useState(""); + const [duration, setDuration] = useState(2200); + + const showToast = useCallback((message: string, duration = 2200) => { + setMessage(message); + setDuration(duration); + }, []); + + const hideToast = useCallback(() => { + setMessage(""); + }, []); + + const contextValue = useMemo( + () => ({ + showToast, + }), + [showToast], + ); + + return ( + + {children} + + + + ); +} + +export function useToast() { + const context = useContext(ToastContext); + + if (context === null) { + throw new Error("useToast must be used inside ToastProvider"); + } + + return context; +} From 6afb45226dff36de320c911ae58e9397516f21e7 Mon Sep 17 00:00:00 2001 From: Jeel Dobariya Date: Mon, 31 Aug 2026 19:30:44 +0530 Subject: [PATCH 4/4] feat: add suppor for error toast message Signed-off-by: Jeel Dobariya --- .../(password-manager)/password-details.tsx | 39 +++++++++++-------- .../(password-manager)/save-password.tsx | 9 ++++- src/components/ToastMessage.tsx | 12 ++++-- src/contexts/ToastContext.tsx | 20 ++++++---- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/app/(tabs)/(password-manager)/password-details.tsx b/src/app/(tabs)/(password-manager)/password-details.tsx index d86ba751..193be56a 100644 --- a/src/app/(tabs)/(password-manager)/password-details.tsx +++ b/src/app/(tabs)/(password-manager)/password-details.tsx @@ -78,23 +78,28 @@ export default function PasswordDetailsScreen() { */ unfocusAllFields(); - await drizzleDb - .update(passwords) - .set({ - domain, - username, - password, - url, - notes, - updatedAt: new Date().toISOString(), - }) - .where(eq(passwords.id, Number(id))); - - setIsEditing(false); - - await loadAndRefreshPassword(); - - showToast("Password saved successfully"); + try { + await drizzleDb + .update(passwords) + .set({ + domain, + username, + password, + url, + notes, + updatedAt: new Date().toISOString(), + }) + .where(eq(passwords.id, Number(id))); + + setIsEditing(false); + + await loadAndRefreshPassword(); + + showToast("Password updated successfully"); + } catch (error) { + showToast("Failed to update; please try again", "error"); + console.error("Failed to update password:", error); + } } function handleCancel() { diff --git a/src/app/(tabs)/(password-manager)/save-password.tsx b/src/app/(tabs)/(password-manager)/save-password.tsx index d066b069..a721a9bd 100644 --- a/src/app/(tabs)/(password-manager)/save-password.tsx +++ b/src/app/(tabs)/(password-manager)/save-password.tsx @@ -1,5 +1,6 @@ import FormTextField from "@/components/FormTextField"; import SecureTextField from "@/components/SecureTextField"; +import { useToast } from "@/contexts/ToastContext"; import { passwords } from "@/db/schema"; import { getScreenShotSecureScreen } from "@/libs/screenshot_prevention"; import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; @@ -21,6 +22,8 @@ export default function SavePasswordScreen() { const db = useSQLiteContext(); const drizzleDb = drizzle(db); + const { showToast } = useToast(); + getScreenShotSecureScreen(); return ( @@ -110,9 +113,13 @@ export default function SavePasswordScreen() { notes, url, }) - .then(() => router.back()) + .then(() => { + showToast("Password saved successfully"); + router.back(); + }) .catch((err) => { console.error(err); + showToast("Failed to save, please try again!!", "error"); Alert.alert("Error", "Failed to save password."); }); }} diff --git a/src/components/ToastMessage.tsx b/src/components/ToastMessage.tsx index 2be9dd0c..36392c40 100644 --- a/src/components/ToastMessage.tsx +++ b/src/components/ToastMessage.tsx @@ -1,13 +1,14 @@ -// components/ToastMessage.tsx - import FontAwesome6 from "@react-native-vector-icons/fontawesome6"; import { useEffect, useRef } from "react"; import { Animated, View } from "react-native"; import { Text } from "react-native-paper"; +export type ToastType = "success" | "error"; + type ToastMessageProps = { message: string; visible: boolean; + type?: ToastType; duration?: number; onHide: () => void; }; @@ -15,6 +16,7 @@ type ToastMessageProps = { export default function ToastMessage({ message, visible, + type = "success", duration = 2200, onHide, }: ToastMessageProps) { @@ -56,6 +58,8 @@ export default function ToastMessage({ return null; } + const isError = type === "error"; + return ( void; + showToast: (message: string, type?: ToastType, duration?: number) => void; }; const ToastContext = createContext(null); export function ToastProvider({ children }: PropsWithChildren) { const [message, setMessage] = useState(""); + const [type, setType] = useState("success"); const [duration, setDuration] = useState(2200); - const showToast = useCallback((message: string, duration = 2200) => { - setMessage(message); - setDuration(duration); - }, []); + const showToast = useCallback( + (message: string, type: ToastType = "success", duration = 2200) => { + setMessage(message); + setType(type); + setDuration(duration); + }, + [], + ); const hideToast = useCallback(() => { setMessage(""); @@ -43,6 +46,7 @@ export function ToastProvider({ children }: PropsWithChildren) {