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