diff --git a/package-lock.json b/package-lock.json
index 50d08a16..5f4938b0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,14 @@
"name": "passcodes",
"version": "1.0.0",
"dependencies": {
+ "@react-native-community/slider": "5.2.0",
"@react-native-vector-icons/fontawesome6": "^13.1.2",
"@shopify/flash-list": "2.0.2",
"babel-plugin-inline-import": "^3.0.0",
"drizzle-orm": "^0.45.2",
"expo": "~57.0.9",
"expo-build-properties": "~57.0.8",
+ "expo-clipboard": "~57.0.1",
"expo-constants": "~57.0.3",
"expo-dev-client": "~57.0.10",
"expo-file-system": "~57.0.0",
@@ -3430,6 +3432,12 @@
}
}
},
+ "node_modules/@react-native-community/slider": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.2.0.tgz",
+ "integrity": "sha512-484sH8aWEaSjxaZ7HT3YZ8CKDcNes2synko1vdEz5DFEdvKAduxKJTj22L/qBMD7rtIkfbX69DMzWDAGbOAV6w==",
+ "license": "MIT"
+ },
"node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz",
@@ -7224,6 +7232,17 @@
"node": ">=10"
}
},
+ "node_modules/expo-clipboard": {
+ "version": "57.0.1",
+ "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.1.tgz",
+ "integrity": "sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-constants": {
"version": "57.0.8",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.8.tgz",
diff --git a/package.json b/package.json
index 929cae3f..21ac9582 100644
--- a/package.json
+++ b/package.json
@@ -18,12 +18,14 @@
"deploy:x86_64": "eas build --profile deploy-x86_64 --platform android --local"
},
"dependencies": {
+ "@react-native-community/slider": "5.2.0",
"@react-native-vector-icons/fontawesome6": "^13.1.2",
"@shopify/flash-list": "2.0.2",
"babel-plugin-inline-import": "^3.0.0",
"drizzle-orm": "^0.45.2",
"expo": "~57.0.9",
"expo-build-properties": "~57.0.8",
+ "expo-clipboard": "~57.0.1",
"expo-constants": "~57.0.3",
"expo-dev-client": "~57.0.10",
"expo-file-system": "~57.0.0",
diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx
index 6357da9f..bcd81e2f 100644
--- a/src/app/(tabs)/_layout.tsx
+++ b/src/app/(tabs)/_layout.tsx
@@ -15,6 +15,20 @@ export default function TabLayout() {
options={{
title: "Passcodes",
tabBarIcon: ({ size, color }) => (
+
+ ),
+ }}
+ />
+ (
(
+ DEFAULT_PASSWORD_OPTIONS,
+ );
+
+ const [password, setPassword] = useState(() =>
+ generatePassword(DEFAULT_PASSWORD_OPTIONS),
+ );
+
+ const strength = getPasswordStrength(options);
+
+ /*
+ * Regenerate the password whenever any generator
+ * option changes.
+ */
+ useEffect(() => {
+ setPassword(generatePassword(options));
+ }, [options]);
+
+ function updateOption(key: CharacterType) {
+ setOptions((current) => {
+ const enabledCharacterTypes = CHARACTER_OPTIONS.filter(
+ (option) => current[option.key],
+ );
+
+ /*
+ * Keep at least one character type enabled.
+ */
+ if (current[key] && enabledCharacterTypes.length === 1) {
+ return current;
+ }
+
+ return {
+ ...current,
+ [key]: !current[key],
+ };
+ });
+ }
+
+ function updateLength(length: number) {
+ setOptions((current) => ({
+ ...current,
+ length: Math.round(length),
+ }));
+ }
+
+ function generate() {
+ setPassword(generatePassword(options));
+ }
+
+ async function copyPassword() {
+ if (!password) {
+ return;
+ }
+
+ await Clipboard.setStringAsync(password);
+ }
+
+ return (
+
+
+
+ Password Generator
+
+
+ {/* Generated Password */}
+
+
+
+
+ Generated Password
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Password Length */}
+
+
+
+
+
+ Length
+
+
+ Choose the length of your password
+
+
+
+
+ {options.length}
+
+
+
+
+
+
+ {MIN_PASSWORD_LENGTH}
+
+ {MAX_PASSWORD_LENGTH}
+
+
+
+
+ {/* Character Types */}
+
+
+
+
+ Character Types
+
+
+ Choose which characters can be used
+
+
+
+
+ {CHARACTER_OPTIONS.map((option, index) => (
+
+ updateOption(option.key)}
+ />
+
+ {index < CHARACTER_OPTIONS.length - 1 && }
+
+ ))}
+
+
+
+
+ {/* Password Strength */}
+
+
+
+
+
+ Password Strength
+
+
+ Based on length and character variety
+
+
+
+
+ {strength.label}
+
+
+
+
+
+
+
+
+ );
+}
+
+type GeneratorOptionProps = {
+ label: string;
+ example: string;
+ enabled: boolean;
+ onPress: () => void;
+};
+
+function GeneratorOption({
+ label,
+ example,
+ enabled,
+ onPress,
+}: GeneratorOptionProps) {
+ return (
+
+ );
+}
+
+function StrengthIndicator({ score }: { score: number }) {
+ return (
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+
+ Weak
+
+ Strong
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ paddingVertical: 12,
+ },
+
+ content: {
+ paddingHorizontal: 12,
+ paddingBottom: 96,
+ gap: 24,
+ },
+
+ title: {
+ marginTop: 8,
+ marginBottom: 4,
+ marginHorizontal: 4,
+ textAlign: "center",
+ },
+
+ cardContent: {
+ padding: 16,
+ gap: 16,
+ },
+
+ sectionLabel: {
+ opacity: 0.7,
+ },
+
+ actionContainer: {
+ flexDirection: "row-reverse",
+ gap: 8,
+ },
+
+ sectionHeader: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: 16,
+ },
+
+ headerText: {
+ flex: 1,
+ },
+
+ description: {
+ opacity: 0.6,
+ marginTop: 3,
+ },
+
+ lengthValue: {
+ minWidth: 40,
+ textAlign: "center",
+ },
+
+ rangeLabels: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ opacity: 0.6,
+ },
+
+ optionList: {
+ marginHorizontal: -8,
+ },
+
+ optionButton: {
+ marginHorizontal: 0,
+ },
+
+ optionButtonContent: {
+ justifyContent: "flex-start",
+ },
+
+ strengthContainer: {
+ gap: 8,
+ },
+
+ strengthBars: {
+ flexDirection: "row",
+ gap: 5,
+ },
+
+ strengthBar: {
+ flex: 1,
+ height: 7,
+ borderRadius: 4,
+ backgroundColor: "#ddd",
+ },
+
+ strengthBarActive: {
+ backgroundColor: "#236636",
+ },
+
+ strengthLabels: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ opacity: 0.6,
+ },
+});
diff --git a/src/utils/generating.ts b/src/utils/generating.ts
new file mode 100644
index 00000000..4cd1135e
--- /dev/null
+++ b/src/utils/generating.ts
@@ -0,0 +1,130 @@
+export const MIN_PASSWORD_LENGTH = 8;
+export const MAX_PASSWORD_LENGTH = 64;
+
+const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
+const NUMBERS = "0123456789";
+const SYMBOLS = "!@#$%^&*()-_=+[]{};:,.<>?";
+
+export type PasswordGeneratorOptions = {
+ length: number;
+ uppercase: boolean;
+ lowercase: boolean;
+ numbers: boolean;
+ symbols: boolean;
+};
+
+export type PasswordStrength = {
+ score: number;
+ label: "Weak" | "Medium" | "Strong";
+};
+
+export type CharacterType = keyof Omit;
+
+function randomCharacter(characters: string): string {
+ return characters[Math.floor(Math.random() * characters.length)];
+}
+
+export function generatePassword(options: PasswordGeneratorOptions): string {
+ const characterSets: string[] = [];
+
+ if (options.uppercase) {
+ characterSets.push(UPPERCASE);
+ }
+
+ if (options.lowercase) {
+ characterSets.push(LOWERCASE);
+ }
+
+ if (options.numbers) {
+ characterSets.push(NUMBERS);
+ }
+
+ if (options.symbols) {
+ characterSets.push(SYMBOLS);
+ }
+
+ if (characterSets.length === 0) {
+ return "";
+ }
+
+ const allCharacters = characterSets.join("");
+
+ const characters: string[] = [];
+
+ // Guarantee that every selected character type
+ // appears at least once.
+ for (const characterSet of characterSets) {
+ characters.push(randomCharacter(characterSet));
+ }
+
+ while (characters.length < options.length) {
+ characters.push(randomCharacter(allCharacters));
+ }
+
+ // Fisher-Yates shuffle.
+ for (let i = characters.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+
+ [characters[i], characters[j]] = [characters[j], characters[i]];
+ }
+
+ return characters.join("");
+}
+
+export function getPasswordStrength(
+ options: PasswordGeneratorOptions,
+): PasswordStrength {
+ let score = 0;
+
+ if (options.length >= 12) {
+ score++;
+ }
+
+ if (options.length >= 20) {
+ score++;
+ }
+
+ if (options.uppercase) {
+ score++;
+ }
+
+ if (options.lowercase) {
+ score++;
+ }
+
+ if (options.numbers) {
+ score++;
+ }
+
+ if (options.symbols) {
+ score++;
+ }
+
+ if (score <= 2) {
+ return {
+ score,
+ label: "Weak",
+ };
+ }
+
+ if (score <= 4) {
+ return {
+ score,
+ label: "Medium",
+ };
+ }
+
+ return {
+ score,
+ label: "Strong",
+ };
+}
+
+export const DEFAULT_PASSWORD_OPTIONS: PasswordGeneratorOptions = {
+ length: 20,
+ uppercase: true,
+ lowercase: true,
+ numbers: true,
+ symbols: true,
+};