diff --git a/src/app/(tabs)/(password-manager)/password-details.tsx b/src/app/(tabs)/(password-manager)/password-details.tsx index b47bbc57..193be56a 100644 --- a/src/app/(tabs)/(password-manager)/password-details.tsx +++ b/src/app/(tabs)/(password-manager)/password-details.tsx @@ -1,17 +1,22 @@ 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 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 { Keyboard, ScrollView, TextInput, View } from "react-native"; + +import { Button, FAB } from "react-native-paper"; import { SafeAreaView } from "react-native-safe-area-context"; export default function PasswordDetailsScreen() { @@ -29,8 +34,23 @@ export default function PasswordDetailsScreen() { const [isEditing, setIsEditing] = useState(false); + const { showToast } = useToast(); + 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(); + } + async function loadAndRefreshPassword() { const result = await drizzleDb .select() @@ -50,45 +70,75 @@ export default function PasswordDetailsScreen() { } async function updatePassword() { - await drizzleDb - .update(passwords) - .set({ - domain, - username, - password, - url, - notes, - updatedAt: new Date().toISOString(), - }) - .where(eq(passwords.id, Number(id))); - - Keyboard.dismiss(); - setIsEditing(false); - router.back(); + /* + * Blur BEFORE changing isEditing. + * + * This prevents the TextInput from remaining focused when + * it becomes read-only. + */ + unfocusAllFields(); + + 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); + } } - usePreventRemove(isEditing, () => { - Keyboard.dismiss(); + 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, () => handleCancel()); useEffect(() => { loadAndRefreshPassword(); }, []); - function handleCancel() { - Keyboard.dismiss(); - setIsEditing(false); - loadAndRefreshPassword(); - } - return ( @@ -146,69 +196,56 @@ export default function PasswordDetailsScreen() { gap: 4, }} > - {!isEditing ? ( - <> - - - - - ) : ( - <> - - - - + {!isEditing && ( + + )} + + {isEditing && ( + )} + + ( + + )} + onPress={handleFabPress} + /> ); } 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/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 ); 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/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; diff --git a/src/components/ToastMessage.tsx b/src/components/ToastMessage.tsx new file mode 100644 index 00000000..36392c40 --- /dev/null +++ b/src/components/ToastMessage.tsx @@ -0,0 +1,113 @@ +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; +}; + +export default function ToastMessage({ + message, + visible, + type = "success", + 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; + } + + const isError = type === "error"; + + return ( + + + + + + {message} + + + + ); +} diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 00000000..7229642a --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,66 @@ +import ToastMessage, { ToastType } from "@/components/ToastMessage"; + +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +type ToastContextValue = { + 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, type: ToastType = "success", duration = 2200) => { + setMessage(message); + setType(type); + 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; +}