diff --git a/client/package.json b/client/package.json
index 83df71b..817fabf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -24,8 +24,10 @@
"class-variance-authority": "^0.7.1",
"expo": "53.0.12",
"expo-blur": "~14.1.5",
+ "expo-camera": "~16.1.10",
"expo-constants": "~17.1.6",
"expo-dev-client": "~5.2.1",
+ "expo-file-system": "~18.1.11",
"expo-gradle-ext-vars": "^0.1.3",
"expo-haptics": "~14.1.4",
"expo-linking": "~7.1.5",
diff --git a/client/src/components/overlays/camera.tsx b/client/src/components/overlays/camera.tsx
new file mode 100644
index 0000000..75fd738
--- /dev/null
+++ b/client/src/components/overlays/camera.tsx
@@ -0,0 +1,165 @@
+import { IconContainer } from "@/components/ui/icons/icon-container";
+import { Icons } from "@/components/ui/icons/icons";
+import { BlurView } from "expo-blur";
+import React, { useEffect } from "react";
+import {
+ Pressable,
+ View,
+ ScrollView as ScrollViewType,
+ Platform,
+ KeyboardAvoidingView,
+ StyleSheet,
+} from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { twMerge } from "tailwind-merge";
+import { Button } from "../ui/button";
+import { logger } from "@/shared/instances/logger.instance";
+import { useCamera } from "@/shared/hooks/useCamera";
+import { CameraView } from "expo-camera";
+
+interface CameraOverlayProps {
+ visible?: boolean;
+ onClose: () => void;
+ onSucces: () => void;
+}
+
+export const CameraOverlay = ({
+ visible,
+ onClose,
+ onSucces,
+}: CameraOverlayProps) => {
+ const {
+ cameraRef,
+ permission,
+ requestPermission,
+ facing,
+ toggleFacing,
+ takePhoto,
+ } = useCamera();
+
+ const opacity = useSharedValue(0);
+
+ useEffect(() => {
+ if (visible) {
+ opacity.value = withTiming(1, { duration: 300 });
+ } else {
+ opacity.value = withTiming(0, { duration: 300 });
+ }
+ }, [visible]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ }));
+
+ if (!permission) {
+ return ;
+ }
+ return (
+
+
+
+
+
+
+ {!permission.granted && (
+
+ )}
+
+ {permission.granted && visible && (
+
+ )}
+
+
+
+
+ {}} className="opacity-0">
+
+
+
+
+ {
+ try {
+ const photo = await takePhoto();
+ onSucces();
+ } catch (error) {
+ logger.error("ui", "Error taking photo");
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: "center",
+ },
+ message: {
+ textAlign: "center",
+ paddingBottom: 10,
+ },
+ camera: {
+ flex: 1,
+ width: 360,
+ height: 360,
+ },
+ buttonContainer: {
+ flex: 1,
+ flexDirection: "row",
+ backgroundColor: "transparent",
+ margin: 64,
+ },
+ button: {
+ flex: 1,
+ alignSelf: "flex-end",
+ alignItems: "center",
+ },
+ text: {
+ fontSize: 24,
+ fontWeight: "bold",
+ color: "white",
+ },
+});
diff --git a/client/src/components/overlays/send-photo.tsx b/client/src/components/overlays/send-photo.tsx
new file mode 100644
index 0000000..3e27e34
--- /dev/null
+++ b/client/src/components/overlays/send-photo.tsx
@@ -0,0 +1,148 @@
+import { IconContainer } from "@/components/ui/icons/icon-container";
+import { Icons } from "@/components/ui/icons/icons";
+import { BlurView } from "expo-blur";
+import React, { useEffect, useState } from "react";
+import {
+ Pressable,
+ View,
+ Platform,
+ KeyboardAvoidingView,
+ StyleSheet,
+ Text,
+ Image,
+} from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { twMerge } from "tailwind-merge";
+import { Button } from "../ui/button";
+import { useCameraStore } from "@/shared/stores/camera.store";
+import { uploadImageToS3 } from "@/shared/api/s3.api";
+import { logger } from "@/shared/instances/logger.instance";
+import { useAuthStore } from "@/shared/stores/auth.store";
+import { useGameStore } from "@/shared/stores/game.store";
+import { Role } from "@/shared/interfaces/game/role";
+
+interface CameraOverlayProps {
+ visible?: boolean;
+ onClose: () => void;
+}
+
+export const SendPhotoOverlay = ({ visible, onClose }: CameraOverlayProps) => {
+ const { photo } = useCameraStore();
+ const { token } = useAuthStore();
+
+ const { game } = useGameStore();
+ const { user } = useAuthStore();
+ const opacity = useSharedValue(0);
+
+ useEffect(() => {
+ if (visible) {
+ opacity.value = withTiming(1, { duration: 300 });
+ } else {
+ opacity.value = withTiming(0, { duration: 300 });
+ }
+ }, [visible]);
+
+ const [role, setRole] = useState("catcher");
+ useEffect(() => {
+ const userRole = game?.players.find((x) => x.user.id === user?.id)?.role;
+ if (userRole) setRole(userRole);
+ }, [game]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: "center",
+ },
+ message: {
+ textAlign: "center",
+ paddingBottom: 10,
+ },
+ camera: {
+ zIndex: 1000,
+ flex: 1,
+ width: 360,
+ height: 360,
+ },
+ buttonContainer: {
+ flex: 1,
+ flexDirection: "row",
+ backgroundColor: "transparent",
+ margin: 64,
+ },
+ button: {
+ flex: 1,
+ alignSelf: "flex-end",
+ alignItems: "center",
+ },
+ text: {
+ fontSize: 24,
+ fontWeight: "bold",
+ color: "white",
+ },
+});
diff --git a/client/src/components/screens/main/game/game.screen.tsx b/client/src/components/screens/main/game/game.screen.tsx
index 76d4406..b914a3c 100644
--- a/client/src/components/screens/main/game/game.screen.tsx
+++ b/client/src/components/screens/main/game/game.screen.tsx
@@ -33,10 +33,14 @@ import { BlurView } from "expo-blur";
import { RouteMarker } from "@/components/ui/map/route-marker";
import { PauseScreen } from "./pause.screen";
import { isPause } from "@/shared/interfaces/polls/pause";
+import { CameraOverlay } from "@/components/overlays/camera";
+import { useCamera } from "@/shared/hooks/useCamera";
+import { SendPhotoOverlay } from "@/components/overlays/send-photo";
import { ModalCardProps } from "@/components/widgets/modal-card";
import { useModal } from "@/shared/hooks/useModal";
import { isTaskPoll } from '@/shared/interfaces/polls/task-poll';
+
type NavProp = StackNavigationProp<
GameStackParamList & MainStackParamList,
"Game"
@@ -55,6 +59,10 @@ export const GameScreen = () => {
const [chatOpened, setChatOpened] = useState(false);
const [pauseOpened, setPauseOpened] = useState(false);
+ const [cameraOverlayOpened, setCameraOverlayOpened] =
+ useState(false);
+ const [sendPhotoOverlayOpened, setSendPhotoOverlayOpened] =
+ useState(false);
const { setModalOpen } = useModal();
@@ -136,47 +144,49 @@ export const GameScreen = () => {
return (
-
+ )}
{!chatOpened && (
@@ -184,7 +194,9 @@ export const GameScreen = () => {
{pauseOpened ? : }
-
+
+
+
- setChatOpened(false)} />
-
+ {chatOpened && (
+ setChatOpened(false)} />
+ )}
+ {pauseOpened && }
+
+ {cameraOverlayOpened && (
+ setCameraOverlayOpened(false)}
+ onSucces={() => {
+ setCameraOverlayOpened(false);
+ setSendPhotoOverlayOpened(true);
+ }}
+ />
+ )}
+
+ {sendPhotoOverlayOpened && (
+ setSendPhotoOverlayOpened(false)}
+ />
+ )}
{
@@ -104,6 +107,9 @@ export const HomeScreen = () => {
const insets = useSafeAreaInsets();
+ const [cameraOverlayVisible, setCameraOverlayVisible] =
+ useState(false);
+
return (
diff --git a/client/src/components/ui/icons/icons.tsx b/client/src/components/ui/icons/icons.tsx
index cf0ea92..a40603f 100644
--- a/client/src/components/ui/icons/icons.tsx
+++ b/client/src/components/ui/icons/icons.tsx
@@ -1,5 +1,5 @@
import { ViewStyle } from "react-native";
-import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg";
+import Svg, { Circle, ClipPath, Defs, G, Path, Rect } from "react-native-svg";
export const Icons = {
Store: () => {
@@ -244,4 +244,34 @@ export const Icons = {
);
},
+
+ TakePhoto: () => {
+ return (
+
+ );
+ },
+ Revert: () => {
+ return (
+
+ );
+ },
};
diff --git a/client/src/shared/api/s3.api.ts b/client/src/shared/api/s3.api.ts
new file mode 100644
index 0000000..e368827
--- /dev/null
+++ b/client/src/shared/api/s3.api.ts
@@ -0,0 +1,33 @@
+import { api } from "../instances/axios.instance";
+
+export const uploadImageToS3 = async (
+ uri: string,
+ filename?: string,
+ dir: string = "default"
+): Promise<{ url: string }> => {
+ const name = filename ?? uri.split("/").pop() ?? "photo.jpg";
+ const ext = name.split(".").pop()?.toLowerCase();
+ const type = ext === "png" ? "image/png" : "image/jpeg";
+
+ const formData = new FormData();
+ formData.append("file", {
+ uri,
+ type,
+ name,
+ } as any);
+
+ const encodedDir = encodeURIComponent(dir);
+
+ const response = await api.post<{ url: string }>(
+ `/api/v1/images/upload/by_file?dir=${encodedDir}`,
+ formData,
+ {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ Accept: "application/json",
+ },
+ }
+ );
+
+ return response.data;
+};
diff --git a/client/src/shared/hooks/useCamera.ts b/client/src/shared/hooks/useCamera.ts
new file mode 100644
index 0000000..1a78eed
--- /dev/null
+++ b/client/src/shared/hooks/useCamera.ts
@@ -0,0 +1,34 @@
+import { useState, useRef, useEffect } from "react";
+import { CameraType, useCameraPermissions, CameraView } from "expo-camera";
+import { useCameraStore } from "../stores/camera.store";
+export const useCamera = () => {
+ const [permission, requestPermission] = useCameraPermissions();
+ const [facing, setFacing] = useState("back");
+
+ const { setPhoto: setStorePhoto } = useCameraStore();
+
+ const ref = useRef(null);
+
+ const toggleFacing = () => {
+ setFacing((prev) => (prev === "back" ? "front" : "back"));
+ };
+
+ const takePhoto = async () => {
+ if (ref.current) {
+ const result = await ref.current.takePictureAsync();
+ if (result) {
+ setStorePhoto(result);
+ return result;
+ }
+ }
+ };
+
+ return {
+ cameraRef: ref,
+ permission,
+ requestPermission,
+ facing,
+ toggleFacing,
+ takePhoto,
+ };
+};
diff --git a/client/src/shared/stores/camera.store.ts b/client/src/shared/stores/camera.store.ts
new file mode 100644
index 0000000..1a6c26b
--- /dev/null
+++ b/client/src/shared/stores/camera.store.ts
@@ -0,0 +1,12 @@
+import { create } from "zustand";
+import type { CameraCapturedPicture } from "expo-camera";
+
+interface CameraState {
+ photo: CameraCapturedPicture | null;
+ setPhoto: (p: CameraCapturedPicture) => void;
+}
+
+export const useCameraStore = create((set) => ({
+ photo: null,
+ setPhoto: (p) => set({ photo: p }),
+}));
diff --git a/client/yarn.lock b/client/yarn.lock
index c06e2f2..d02c3a6 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -4380,6 +4380,13 @@ expo-blur@~14.1.5:
resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-14.1.5.tgz#910712389e19286ccdc136275bf569f427aa05ef"
integrity sha512-CCLJHxN4eoAl06ESKT3CbMasJ98WsjF9ZQEJnuxtDb9ffrYbZ+g9ru84fukjNUOTtc8A8yXE5z8NgY1l0OMrmQ==
+expo-camera@~16.1.10:
+ version "16.1.10"
+ resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-16.1.10.tgz#56dd7abc118b4de1771e6b64739197c2277f4083"
+ integrity sha512-qoRJeSwPmMbuu0VfnQTC+q79Kt2SqTWColEImgithL9u0qUQcC55U89IfhZk55Hpt6f1DgKuDzUOG5oY+snSWg==
+ dependencies:
+ invariant "^2.2.4"
+
expo-constants@~17.1.5, expo-constants@~17.1.6:
version "17.1.6"
resolved "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.6.tgz"
@@ -4426,6 +4433,11 @@ expo-file-system@~18.1.10:
resolved "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.1.10.tgz"
integrity sha512-SyaWg+HitScLuyEeSG9gMSDT0hIxbM9jiZjSBP9l9zMnwZjmQwsusE6+7qGiddxJzdOhTP4YGUfvEzeeS0YL3Q==
+expo-file-system@~18.1.11:
+ version "18.1.11"
+ resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-18.1.11.tgz#a563c715c4bb5c18729d6d104e8c6cdfbd383e69"
+ integrity sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ==
+
expo-font@~13.3.1:
version "13.3.1"
resolved "https://registry.npmjs.org/expo-font/-/expo-font-13.3.1.tgz"