diff --git a/.gitignore b/.gitignore index f8c6c2e..e97e825 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ app-example # generated native folders /ios /android + +# claude +CLAUDE.md +.claude/ \ No newline at end of file diff --git a/src/entities/chat/index.ts b/src/entities/chat/index.ts new file mode 100644 index 0000000..f6a888e --- /dev/null +++ b/src/entities/chat/index.ts @@ -0,0 +1,2 @@ +export type { ChatRoomItemProps, Message, MessageItemProps } from "./model/types"; +export { ChatRoomItem, MessageBubble, MessageItem, MessageTime } from "./ui"; diff --git a/src/entities/chat/model/types.ts b/src/entities/chat/model/types.ts new file mode 100644 index 0000000..dc322ae --- /dev/null +++ b/src/entities/chat/model/types.ts @@ -0,0 +1,23 @@ +import type { ImageSource } from "expo-image"; + +export interface ChatRoomItemProps { + profileImage: ImageSource; + roomName: string; + lastMessage: string; + unreadCount?: number; +} + +export interface Message { + id: string; + text: string; + senderId: string; + /** "HH:mm" 포맷 */ + sentAt: string; +} + +export interface MessageItemProps { + message: Message; + isMine: boolean; + /** received 메시지일 때만 필요 */ + profileImage?: ImageSource; +} diff --git a/src/entities/chat/ui/ChatRoomItem.tsx b/src/entities/chat/ui/ChatRoomItem.tsx new file mode 100644 index 0000000..1abea3f --- /dev/null +++ b/src/entities/chat/ui/ChatRoomItem.tsx @@ -0,0 +1,46 @@ +import { Text, View } from "react-native"; + +import { ProfileAvatar } from "@/shared/ui"; + +import type { ChatRoomItemProps } from "../model/types"; + +export function ChatRoomItem({ + profileImage, + roomName, + lastMessage, + unreadCount = 0, +}: ChatRoomItemProps) { + return ( + + {/* Left: profile + texts */} + + + + {/* Text area */} + + + {roomName} + + + {lastMessage} + + + + + {/* Right: unread count badge */} + {unreadCount > 0 && ( + + + {unreadCount} + + + )} + + ); +} diff --git a/src/entities/chat/ui/MessageBubble.tsx b/src/entities/chat/ui/MessageBubble.tsx new file mode 100644 index 0000000..c5b8470 --- /dev/null +++ b/src/entities/chat/ui/MessageBubble.tsx @@ -0,0 +1,28 @@ +import { Text, View } from "react-native"; + +interface MessageBubbleProps { + text: string; + variant: "sent" | "received"; +} + +export function MessageBubble({ text, variant }: MessageBubbleProps) { + const isSent = variant === "sent"; + + return ( + + + {text} + + + ); +} diff --git a/src/entities/chat/ui/MessageItem.tsx b/src/entities/chat/ui/MessageItem.tsx new file mode 100644 index 0000000..06272c6 --- /dev/null +++ b/src/entities/chat/ui/MessageItem.tsx @@ -0,0 +1,36 @@ +import { View } from "react-native"; + +import { ProfileAvatar } from "@/shared/ui"; + +import type { MessageItemProps } from "../model/types"; +import { MessageBubble } from "./MessageBubble"; +import { MessageTime } from "./MessageTime"; + +export function MessageItem({ message, isMine, profileImage }: MessageItemProps) { + const variant = isMine ? "sent" : "received"; + + if (isMine) { + return ( + + + {/* shrink: 버블이 시간 텍스트를 밀어내지 않도록 압축 허용 */} + + + + + ); + } + + return ( + + + {/* flex-1: 아바타 이후 남은 공간을 정확히 파악해 버블+시간이 넘치지 않게 함 */} + + + + + + + + ); +} diff --git a/src/entities/chat/ui/MessageTime.tsx b/src/entities/chat/ui/MessageTime.tsx new file mode 100644 index 0000000..e22b57d --- /dev/null +++ b/src/entities/chat/ui/MessageTime.tsx @@ -0,0 +1,19 @@ +import { Text } from "react-native"; + +interface MessageTimeProps { + time: string; + variant: "sent" | "received"; +} + +export function MessageTime({ time, variant }: MessageTimeProps) { + return ( + + {time} + + ); +} diff --git a/src/entities/chat/ui/README.md b/src/entities/chat/ui/README.md new file mode 100644 index 0000000..9e52037 --- /dev/null +++ b/src/entities/chat/ui/README.md @@ -0,0 +1,150 @@ +# entities/chat/ui + +채팅 도메인 UI 컴포넌트 모음. + +--- + +## ChatRoomItem + +채팅방 목록에서 각 행을 표시하는 컴포넌트. + +```tsx +import { ChatRoomItem } from "@/entities/chat"; + + +``` + +| Prop | 타입 | 필수 | 설명 | +|------|------|------|------| +| `profileImage` | `ImageSource` | ✅ | 프로필 이미지 | +| `roomName` | `string` | ✅ | 채팅방 이름 | +| `lastMessage` | `string` | ✅ | 마지막 메시지 | +| `unreadCount` | `number` | ❌ | 읽지 않은 메시지 수. 0이면 배지 미표시. | + +--- + +## MessageItem + +받은 메시지·보낸 메시지를 모두 처리하는 조합 컴포넌트. `isMine` 값에 따라 레이아웃이 달라진다. + +```tsx +import { MessageItem } from "@/entities/chat"; + +// 받은 메시지 + + +// 보낸 메시지 + +``` + +| Prop | 타입 | 필수 | 설명 | +|------|------|------|------| +| `message` | `Message` | ✅ | 메시지 데이터 | +| `isMine` | `boolean` | ✅ | `true`면 오른쪽 정렬(파란 버블), `false`면 왼쪽 정렬(흰 버블) | +| `profileImage` | `ImageSource` | ❌ | 받은 메시지(`isMine=false`)일 때만 사용 | + +### Message 타입 + +```ts +interface Message { + id: string; + text: string; + senderId: string; + sentAt: string; // "HH:mm" 포맷 + unreadCount?: number; // 프로필 우하단 배지 숫자 +} +``` + +--- + +## MessageBubble + +말풍선 단독 컴포넌트. `MessageItem` 내부에서 사용되며, 필요 시 단독으로도 사용 가능. + +```tsx +import { MessageBubble } from "@/entities/chat"; + + + +``` + +| Prop | 타입 | 설명 | +|------|------|------| +| `text` | `string` | 메시지 텍스트 | +| `variant` | `"sent" \| "received"` | `sent`=파란 배경, `received`=neutral 배경 | + +--- + +## MessageTime + +전송 시간 텍스트 단독 컴포넌트. + +```tsx +import { MessageTime } from "@/entities/chat"; + + + +``` + +| Prop | 타입 | 설명 | +|------|------|------| +| `time` | `string` | `"HH:mm"` 포맷 문자열 | +| `variant` | `"sent" \| "received"` | 정렬 방향 결정 | + +--- + +## 채팅방 화면에서의 레이아웃 + +```tsx +import { FlatList, KeyboardAvoidingView, Platform } from "react-native"; +import { MessageItem } from "@/entities/chat"; +import { ChatBar } from "@/features/send-message"; + +function ChatRoomPage() { + return ( + + item.id} + renderItem={({ item }) => ( + + )} + contentContainerStyle={{ gap: 12, paddingVertical: 16 }} + inverted + /> + + + + ); +} +``` diff --git a/src/entities/chat/ui/index.ts b/src/entities/chat/ui/index.ts new file mode 100644 index 0000000..bc3fe9b --- /dev/null +++ b/src/entities/chat/ui/index.ts @@ -0,0 +1,4 @@ +export { ChatRoomItem } from "./ChatRoomItem"; +export { MessageBubble } from "./MessageBubble"; +export { MessageItem } from "./MessageItem"; +export { MessageTime } from "./MessageTime"; diff --git a/src/features/send-message/index.ts b/src/features/send-message/index.ts new file mode 100644 index 0000000..0aec626 --- /dev/null +++ b/src/features/send-message/index.ts @@ -0,0 +1 @@ +export { ChatBar } from "./ui"; diff --git a/src/features/send-message/ui/ChatBar.tsx b/src/features/send-message/ui/ChatBar.tsx new file mode 100644 index 0000000..1100752 --- /dev/null +++ b/src/features/send-message/ui/ChatBar.tsx @@ -0,0 +1,59 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useState } from "react"; +import { TextInput, TouchableOpacity, View } from "react-native"; + +import { colorTokens } from "@/shared/styles/tokens"; + +interface ChatBarProps { + onSend: (message: string) => void; + onAttach?: () => void; +} + +export function ChatBar({ + onSend, + onAttach, +}: ChatBarProps) { + const [text, setText] = useState(""); + + const canSend = text.trim().length > 0; + + function handleSend() { + if (!canSend) return; + onSend(text.trim()); + setText(""); + } + + return ( + + + {/* 첨부(+) 버튼 */} + + + + + {/* 텍스트 입력 */} + + + {/* 전송 버튼 */} + + + + + + ); +} diff --git a/src/features/send-message/ui/README.md b/src/features/send-message/ui/README.md new file mode 100644 index 0000000..f88436c --- /dev/null +++ b/src/features/send-message/ui/README.md @@ -0,0 +1,55 @@ +# ChatBar + +메시지 전송을 위한 채팅 입력 바 컴포넌트입니다. 텍스트가 길어지면 입력창이 위로 자동 확장됩니다. + +## 사용법 + +```tsx +import { ChatBar } from "@/features/send-message"; + + console.log(message)} + onAttach={() => console.log("attach pressed")} +/> +``` + +## Props + +| Prop | 타입 | 필수 여부 | 설명 | +|------|------|----------|------| +| `onSend` | `(message: string) => void` | ✅ | 전송 버튼 클릭 시 호출. 앞뒤 공백이 제거된 텍스트가 전달되며, 전송 후 입력창은 초기화됩니다. | +| `onAttach` | `() => void` | ❌ | `+` 버튼 클릭 시 호출. | + +## 동작 방식 + +- 입력창이 비어있으면 전송 버튼이 **비활성화**(회색), 텍스트가 있으면 **활성화**(파란색)됩니다. +- 텍스트가 한 줄을 넘으면 입력창이 위로 확장되며, 아이콘은 하단에 고정됩니다. +- `onSend`에는 앞뒤 공백이 제거된 텍스트가 전달됩니다. +- Android 기본 `TextInput` 패딩을 제거하여 플랫폼 간 정렬이 일관되게 유지됩니다. + +## 채팅 화면에서의 레이아웃 + +```tsx +import { KeyboardAvoidingView, Platform } from "react-native"; +import { ChatBar } from "@/features/send-message"; + +function ChatRoomPage() { + function handleSend(message: string) { + // 서버에 메시지 전송 + } + + return ( + + {/* 메시지 목록 */} + + + + + ); +} +``` + +> **주의:** 키보드가 올라올 때 `ChatBar`가 함께 올라오도록 `KeyboardAvoidingView`로 화면을 감싸야 합니다. diff --git a/src/features/send-message/ui/index.ts b/src/features/send-message/ui/index.ts new file mode 100644 index 0000000..724376a --- /dev/null +++ b/src/features/send-message/ui/index.ts @@ -0,0 +1 @@ +export { ChatBar } from "./ChatBar"; diff --git a/src/shared/assets/fonts/Pretendard-ExtraLight.otf b/src/shared/assets/fonts/Pretendard-ExtraLight.otf new file mode 100644 index 0000000..40c8b69 Binary files /dev/null and b/src/shared/assets/fonts/Pretendard-ExtraLight.otf differ diff --git a/src/shared/assets/fonts/Pretendard-Light.otf b/src/shared/assets/fonts/Pretendard-Light.otf new file mode 100644 index 0000000..228679e Binary files /dev/null and b/src/shared/assets/fonts/Pretendard-Light.otf differ diff --git a/src/shared/assets/images/default-profile.png b/src/shared/assets/images/default-profile.png new file mode 100644 index 0000000..755ec18 Binary files /dev/null and b/src/shared/assets/images/default-profile.png differ diff --git a/src/shared/lib/hooks/useLoadFonts.ts b/src/shared/lib/hooks/useLoadFonts.ts index f4e89e8..b779ff8 100644 --- a/src/shared/lib/hooks/useLoadFonts.ts +++ b/src/shared/lib/hooks/useLoadFonts.ts @@ -7,6 +7,8 @@ SplashScreen.preventAutoHideAsync(); export function useLoadFonts() { const [fontsLoaded] = useFonts({ + "Pretendard-ExtraLight": require("@/shared/assets/fonts/Pretendard-ExtraLight.otf"), + "Pretendard-Light": require("@/shared/assets/fonts/Pretendard-Light.otf"), "Pretendard-Regular": require("@/shared/assets/fonts/Pretendard-Regular.otf"), "Pretendard-Medium": require("@/shared/assets/fonts/Pretendard-Medium.otf"), "Pretendard-SemiBold": require("@/shared/assets/fonts/Pretendard-SemiBold.otf"), diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index e4c0223..ac09c9b 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,2 +1,2 @@ +export * from "./profile"; export * from "./select"; - diff --git a/src/shared/ui/profile/ProfileAvatar.tsx b/src/shared/ui/profile/ProfileAvatar.tsx new file mode 100644 index 0000000..c0f81f4 --- /dev/null +++ b/src/shared/ui/profile/ProfileAvatar.tsx @@ -0,0 +1,28 @@ +import { Image } from "expo-image"; +import type { ImageSource } from "expo-image"; +import { View } from "react-native"; + +const DEFAULT_PROFILE = require("@/shared/assets/images/default-profile.png"); + +interface ProfileAvatarProps { + source?: ImageSource; + /** 프레임 크기 (px). 이미지는 size-2 만큼 작게 렌더링. default: 48 */ + size?: number; +} + +export function ProfileAvatar({ source, size = 48 }: ProfileAvatarProps) { + const imageSize = size - 2; + + return ( + + + + ); +} diff --git a/src/shared/ui/profile/index.ts b/src/shared/ui/profile/index.ts new file mode 100644 index 0000000..fa4c323 --- /dev/null +++ b/src/shared/ui/profile/index.ts @@ -0,0 +1 @@ +export { ProfileAvatar } from "./ProfileAvatar"; diff --git a/tailwind.config.js b/tailwind.config.js index e643e37..167541e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -46,6 +46,8 @@ module.exports = { plugins: [ ({ addUtilities }) => { addUtilities({ + ".font-extralight": { fontFamily: "Pretendard-ExtraLight" }, + ".font-light": { fontFamily: "Pretendard-Light" }, ".font-regular": { fontFamily: "Pretendard-Regular" }, ".font-medium": { fontFamily: "Pretendard-Medium" }, ".font-semibold": { fontFamily: "Pretendard-SemiBold" },