Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions app/(authed)/(home)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { DrawerToggleButton } from "@react-navigation/drawer";
import { Stack } from "expo-router";
import { View } from "react-native";
import ProfileHeader from "../../../components/sidebar/ProfileHeader";
import HeaderBack from "../../../components/ui/HeaderBack";
import HeaderRight from "../../../components/ui/HeaderRight";

export default function HomeLayout() {
return (
Expand All @@ -11,20 +14,25 @@ export default function HomeLayout() {
headerTitleStyle: {
fontWeight: "bold",
},
headerRight: () => <HeaderRight />,
}}
>
<Stack.Screen
name="index"
options={{
title: "SplitZone",
headerLeft: () => <DrawerToggleButton />,
headerLeft: () => (
<View className="ml-4">
<ProfileHeader />
</View>
),
}}
/>
<Stack.Screen
name="group/[groupId]"
options={{
title: "Group Details",
headerBackTitle: "Back",
headerLeft: () => <HeaderBack />,
}}
/>
</Stack>
Expand Down
30 changes: 15 additions & 15 deletions app/(authed)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,41 @@
import { Drawer } from "expo-router/drawer";
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import CustomDrawerContent from "../../components/sidebar/CustomDrawerContent";
import HeaderBack from "../../components/ui/HeaderBack";
import HeaderRight from "../../components/ui/HeaderRight";
import HomeTitle from "../../components/ui/HomeTitle";

export default function DrawerLayout() {
export default function AuthedLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Drawer
drawerContent={(props) => <CustomDrawerContent {...props} />}
<Stack
screenOptions={{
headerShown: true,
headerTitle: "SplitZone",
drawerActiveTintColor: "#2563EB",
headerTitle: () => <HomeTitle />,
headerRight: () => <HeaderRight />,
headerBackTitle: "Back",
}}
>
<Drawer.Screen
<Stack.Screen
name="(home)"
options={{
drawerLabel: "Home",
title: "SplitZone",
headerShown: false,
}}
/>
<Drawer.Screen
<Stack.Screen
name="settings/index"
options={{
drawerLabel: "Settings",
title: "Settings",
headerLeft: () => <HeaderBack />,
}}
/>
<Drawer.Screen
<Stack.Screen
name="join/[code]"
options={{
drawerItemStyle: { display: "none" },
title: "Join Group",
headerLeft: () => <HeaderBack />,
}}
/>
</Drawer>
</Stack>
</GestureHandlerRootView>
);
}
194 changes: 179 additions & 15 deletions app/(authed)/settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,38 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "convex/react";
import * as ImagePicker from "expo-image-picker";
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor this file

import { Redirect } from "expo-router";
import { ChevronRight } from "lucide-react-native";
import { ChevronRight, Image as ImageIcon } from "lucide-react-native";
import { useColorScheme } from "nativewind";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
import { z } from "zod";
import {
ActionSheetModal,
type ActionSheetOption,
} from "../../../components/ui/ActionSheetModal";
import { FormModal } from "../../../components/ui/FormModal";
import UserAvatar from "../../../components/ui/UserAvatar";
import { useToast } from "../../../context/ToastContext";
import { api } from "../../../convex/_generated/api";

const settingsSchema = z.object({
name: z.string().min(3, "Name must be at least 3 characters"),
name: z.optional(z.string().min(3, "Name must be at least 3 characters")),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this optional in name

phone: z.optional(z.string()),
});

type SettingsFormData = z.infer<typeof settingsSchema>;

export default function Settings() {
const { colorScheme } = useColorScheme();
const user = useQuery(api.users.getCurrentlyLoggedInUser);
const updateUserName = useMutation(api.users.updateUserName);
const updateUserProfile = useMutation(api.users.updateUserProfile);
const generateUploadUrl = useMutation(api.users.generateUploadUrl);
const updateUserImage = useMutation(api.users.updateUserImage);
const toast = useToast();
const [isModalVisible, setIsModalVisible] = useState(false);
const [isNameModalVisible, setIsNameModalVisible] = useState(false);
const [isPhoneModalVisible, setIsPhoneModalVisible] = useState(false);

const {
control,
Expand All @@ -31,25 +43,88 @@ export default function Settings() {
resolver: zodResolver(settingsSchema),
defaultValues: {
name: "",
phone: "",
},
});

useEffect(() => {
if (user?.name) {
setValue("name", user.name);
}
if (user?.phone) {
setValue("phone", user.phone);
}
}, [user, setValue]);

const onSubmit = async (data: SettingsFormData) => {
try {
await updateUserName({ name: data.name });
toast.success("Name updated successfully!");
setIsModalVisible(false);
await updateUserProfile({ name: data.name, phone: data.phone });
toast.success("Profile updated successfully!");
setIsNameModalVisible(false);
setIsPhoneModalVisible(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update name");
toast.error(
err instanceof Error ? err.message : "Failed to update profile",
);
}
};

const pickImage = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 1,
});

if (!result.canceled) {
try {
const postUrl = await generateUploadUrl();
const response = await fetch(result.assets[0].uri);
const blob = await response.blob();
const putResponse = await fetch(postUrl, {
method: "POST",
headers: {
"Content-Type": blob.type,
},
body: blob,
});
const { storageId } = await putResponse.json();
await updateUserImage({ storageId });
toast.success("Image updated successfully!");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update image",
);
}
}
};

const handleRemoveImage = async () => {
try {
await updateUserImage({ storageId: undefined });
toast.success("Image removed successfully!");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to remove image",
);
}
};

const [isAvatarSheetVisible, setIsAvatarSheetVisible] = useState(false);

const avatarOptions: ActionSheetOption[] = [
{ label: "Choose from Library", onPress: pickImage },
];

if (user?.image) {
avatarOptions.push({
label: "Remove Photo",
onPress: handleRemoveImage,
isDestructive: true,
});
}

if (!user) {
return <Redirect href={"/auth"} />;
}
Expand All @@ -58,26 +133,76 @@ export default function Settings() {
<View className="flex-1 bg-background p-4">
<Text className="mb-6 text-2xl font-bold text-foreground">Settings</Text>

<View className="overflow-hidden rounded-xl border border-border bg-card">
<View className="items-center">
<TouchableOpacity
onPress={() => setIsAvatarSheetVisible(true)}
className="group relative h-32 w-32 items-center justify-center rounded-full"
>
<UserAvatar
user={user}
className="h-full w-full"
textClassName="text-4xl"
/>
<View className="absolute bottom-0 right-0 rounded-full bg-background p-1">
<View className="rounded-full bg-primary p-2">
<ImageIcon size={16} className="text-primary-foreground" />
</View>
</View>
</TouchableOpacity>
</View>

<View className="mt-6 overflow-hidden rounded-xl border border-border bg-card">
<TouchableOpacity
onPress={() => setIsModalVisible(true)}
onPress={() => setIsNameModalVisible(true)}
className="flex-row items-center justify-between p-4 active:bg-accent"
>
<View>
<Text className="text-base font-medium text-foreground">Name</Text>
<Text className="text-sm text-muted-foreground">{user.name}</Text>
<Text className="text-sm text-muted-foreground">
{user.name ?? "Not set"}
</Text>
</View>
<ChevronRight
size={20}
color={
colorScheme === "dark"
? "hsl(240 5% 64.9%)"
: "hsl(240 3.8% 46.1%)"
}
/>
</TouchableOpacity>
<View className="border-t border-border" />
<View className="flex-row items-center justify-between p-4">
<View>
<Text className="text-base font-medium text-foreground">Email</Text>
<Text className="text-sm text-muted-foreground">{user.email}</Text>
</View>
</View>
<View className="border-t border-border" />
<TouchableOpacity
onPress={() => setIsPhoneModalVisible(true)}
className="flex-row items-center justify-between p-4 active:bg-accent"
>
<View>
<Text className="text-base font-medium text-foreground">Phone</Text>
<Text className="text-sm text-muted-foreground">
{user.phone ?? "Not set"}
</Text>
</View>
<ChevronRight
size={20}
className="text-muted-foreground"
color="hsl(240 3.8% 46.1%)"
color={
colorScheme === "dark"
? "hsl(240 5% 64.9%)"
: "hsl(240 3.8% 46.1%)"
}
/>
</TouchableOpacity>
</View>

<FormModal
visible={isModalVisible}
onClose={() => setIsModalVisible(false)}
visible={isNameModalVisible}
onClose={() => setIsNameModalVisible(false)}
title="Edit Name"
submitText="Save Changes"
onSubmit={handleSubmit(onSubmit)}
Expand Down Expand Up @@ -106,6 +231,45 @@ export default function Settings() {
)}
</View>
</FormModal>

<FormModal
visible={isPhoneModalVisible}
onClose={() => setIsPhoneModalVisible(false)}
title="Edit Phone Number"
submitText="Save Changes"
onSubmit={handleSubmit(onSubmit)}
>
<View>
<Controller
control={control}
name="phone"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
className="w-full rounded-lg border border-input p-4 text-lg text-foreground placeholder:text-muted-foreground"
placeholder="Enter your phone number"
placeholderTextColor="hsl(240 3.8% 46.1%)"
onBlur={onBlur}
onChangeText={onChange}
value={value}
keyboardType="phone-pad"
autoFocus
/>
)}
/>
{errors.phone && (
<Text className="mt-1 text-sm text-destructive">
{errors.phone.message}
</Text>
)}
</View>
</FormModal>

<ActionSheetModal
visible={isAvatarSheetVisible}
title="Profile Photo"
options={avatarOptions}
onCancel={() => setIsAvatarSheetVisible(false)}
/>
</View>
);
}
16 changes: 15 additions & 1 deletion app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ActivityIndicator, Platform, View } from "react-native";
import { ToastProvider } from "../context/ToastContext";
import "../global.css";
import { NAV_THEME } from "../lib/nav-theme";
import { getStorageItemAsync } from "../lib/storage";

// biome-ignore lint/style/noNonNullAssertion: crashing is fine if env is not present
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
Expand All @@ -22,9 +23,22 @@ const secureStorage = {
};

export default function RootLayout() {
const { colorScheme } = useColorScheme();
const { colorScheme, setColorScheme } = useColorScheme();
const isDarkColorScheme = colorScheme === "dark";

useEffect(() => {
(async () => {
try {
const theme = await getStorageItemAsync("theme");
if (theme === "dark" || theme === "light") {
setColorScheme(theme);
}
} catch (e) {
console.error("Failed to load theme preference:", e);
}
})();
}, [setColorScheme]);

return (
<ConvexAuthProvider
client={convex}
Expand Down
Loading