diff --git a/.gitignore b/.gitignore
index aa724b7..d5c0436 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
+/.idea
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b86273d..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index ae733f1..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index 148fdd2..0000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
deleted file mode 100644
index f8051a6..0000000
--- a/.idea/migrations.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 74dd639..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1..0000000
--- a/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 80c8274..3c78e42 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,10 +9,15 @@ android {
compileSdk = 35
viewBinding.isEnabled = true
+ buildFeatures {
+ viewBinding = true
+ dataBinding = true
+ }
+
defaultConfig {
applicationId = "com.example.umc_closit"
minSdk = 24
- targetSdk = 34
+ targetSdk = 35
versionCode = 1
versionName = "1.0"
@@ -48,7 +53,36 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+ implementation("androidx.recyclerview:recyclerview:1.2.1")
+
+ //card view
+ implementation("androidx.cardview:cardview:1.0.0")
+
// Glide
implementation("com.github.bumptech.glide:glide:4.15.1")
annotationProcessor("com.github.bumptech.glide:compiler:4.15.1")
+ implementation("com.google.android.material:material:1.9.0")
+
+ // Splash
+ implementation("androidx.core:core-splashscreen:1.0.1")
+
+ // ViewModel
+ implementation("androidx.fragment:fragment-ktx:1.6.1")
+
+ //camera
+ implementation("androidx.exifinterface:exifinterface:1.3.6")
+
+ //layout
+ implementation ("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+
+ // Retrofit
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-gson:2.9.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
+
+ // SSE
+ implementation("com.launchdarkly:okhttp-eventsource:2.5.0")
+ implementation("com.jakewharton.threetenabp:threetenabp:1.4.4")
+
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 14da6f0..0bacc66 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,25 +2,77 @@
+
+
+
+
+
+
+
+
+
+
+ android:hardwareAccelerated="true">
+
+ android:name=".ui.splash.SplashActivity"
+ android:exported="true"
+ android:theme="@style/Theme.Splash"
+ android:windowSoftInputMode="adjustResize|stateHidden">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/marketing_agreement.txt b/app/src/main/assets/marketing_agreement.txt
new file mode 100644
index 0000000..5f62640
--- /dev/null
+++ b/app/src/main/assets/marketing_agreement.txt
@@ -0,0 +1,25 @@
+제 1 조 목적
+본 동의서는 Closit APP(이하 'APP')이 이용자의 개인정보를 마케팅 목적으로 활용하고 광고성 정보를 제공하기 위해 필요한 사항을 규정함을 목적으로 합니다.
+
+제 2 조 수집 및 이용 항목
+• 이메일, 닉네임, APP 이용 기록(게시글, 좋아요, 팔로우 등)
+
+제 3 조 수집 및 이용 목적
+• 이벤트, 프로모션, 할인 혜택 등 마케팅 정보 제공
+• APP 내 맞춤형 광고 및 콘텐츠 제공
+
+제 4 조 보유 및 이용 기간
+• 회원 탈퇴 또는 동의 철회 시까지 보유하며, 철회 시 지체 없이 파기합니다.
+
+제 5 조 동의 철회
+• 이용자는 언제든지 APP 내 설정 메뉴에서 마케팅 활용 및 광고성 정보 수신 동의를 철회할 수 있습니다.
+
+제 6 조 광고성 정보 발송 방법
+• 이메일, APP 푸시 알림 등을 통해 발송됩니다.
+
+제 7 조 선택 동의 안내
+• 본 동의는 APP 회원가입 시 필수 사항이 아니며, 이용자는 선택적으로 동의할 수 있습니다.
+• 선택 동의를 하지 않더라도 APP의 기본 서비스 이용에는 제한이 없습니다.
+
+제 8 조 문의처
+• 이메일: closit.support@email.com
\ No newline at end of file
diff --git a/app/src/main/assets/privacy_policy.txt b/app/src/main/assets/privacy_policy.txt
new file mode 100644
index 0000000..0a5208d
--- /dev/null
+++ b/app/src/main/assets/privacy_policy.txt
@@ -0,0 +1,40 @@
+제 1 조 목적
+본 개인정보처리방침은 Closit APP(이하 'APP')이 이용자의 개인정보를 어떻게 수집, 이용, 관리 및 보호하는지에 대한 방침을 설명하기 위함입니다.
+
+제 2 조 수집하는 개인정보 항목
+• 회원가입 시: 이메일, 비밀번호, 닉네임
+• 서비스 이용 시: 사진, 게시글, 좋아요, 댓글, 팔로우 정보
+• 자동 수집 정보: 접속 로그, 기기 정보, 쿠키, IP 주소
+
+제 3 조 개인정보 수집 및 이용 목적
+• 회원 관리: 회원 식별, 계정 관리, 비밀번호 복구 등
+• 서비스 제공: 데일리 패션 기록, 타임라인, 오늘의 옷장, 배틀 게시판 등 APP 내 서비스 제공
+• 서비스 개선: 이용 패턴 분석, 앱 기능 개선 및 신규 서비스 개발
+• 마케팅: 광고, 이벤트, 프로모션 안내 (이용자가 동의한 경우)
+
+제 4 조 개인정보 보유 및 이용 기간
+• 회원 탈퇴 시까지 보유하며, 탈퇴 시 지체 없이 파기합니다.
+• 법령에서 정한 경우 해당 기간 동안 보관합니다.
+
+제 5 조 개인정보의 제3자 제공
+• 이용자의 사전 동의 없이 개인정보를 제3자에게 제공하지 않습니다. 다만, 법령에 의해 요구되는 경우 예외로 합니다.
+
+제 6 조 개인정보의 처리위탁
+• 원활한 서비스 제공을 위해 필요한 경우, 이용자의 개인정보 처리를 외부 업체에 위탁할 수 있습니다. (예: 데이터 저장, 분석 등)
+
+제 7 조 개인정보의 파기
+• 개인정보 보유기간이 경과하거나 목적이 달성된 경우 지체 없이 파기합니다.
+• 전자적 파일 형태는 복구 불가능하게 삭제하며, 출력물 등은 분쇄하거나 소각합니다.
+
+제 8 조 이용자의 권리와 행사 방법
+• 이용자는 언제든지 개인정보 열람, 정정, 삭제, 처리 정지를 요청할 수 있습니다.
+• APP 내 설정 메뉴 또는 고객센터를 통해 권리를 행사할 수 있습니다.
+
+제 9 조 개인정보 보호 조치
+• 개인정보 암호화, 접근 제한, 보안 프로그램 설치 등 보호 조치를 시행하고 있습니다.
+
+제 10 조 쿠키 사용
+• 이용자 경험 개선을 위해 쿠키를 사용하며, 이용자는 브라우저 설정을 통해 쿠키 저장을 거부할 수 있습니다.
+
+제 11 조 문의처
+• 이메일: closit.support@email.com
\ No newline at end of file
diff --git a/app/src/main/assets/terms_of_service.txt b/app/src/main/assets/terms_of_service.txt
new file mode 100644
index 0000000..f2002d0
--- /dev/null
+++ b/app/src/main/assets/terms_of_service.txt
@@ -0,0 +1,46 @@
+제 1 조 목적
+이 약관은 Closit(이하 'APP')을 제공하는 Closit 개발팀(이하 '개발자')과 이용자 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.
+
+제 2 조 정의
+• "APP"이란 사용자가 매일 전·후면 카메라로 촬영한 데일리 패션 사진을 업로드하고, 타임라인에서 본인 및 팔로우한 사용자의 스타일을 확인할 수 있으며, '오늘의 옷장'과 '배틀 게시판' 등 커뮤니티 기능을 제공하는 모바일 어플리케이션을 말합니다.
+• "오늘의 옷장"이란 24시간 내 업로드된 게시글 중 하나를 선택하여 등록하고, 조회수와 좋아요 수를 통해 게시글 순위를 매기는 게시판을 의미합니다.
+• "배틀 게시판"이란 한 이용자가 사진과 제목을 올린 게시글에 다른 이용자가 자신의 게시글로 도전하며, 다른 이용자들이 두 게시글 중 하나에 투표하여 승자를 결정하는 기능을 의미합니다.
+• "이용자"란 본 약관에 따라 APP을 설치하고 사용하는 모든 자를 의미합니다.
+
+제 3 조 서비스 제공 및 변경
+• 개발자는 이용자에게 데일리 패션 기록, 사진 업로드, 타임라인 피드 제공, 팔로우/팔로워 관리, 커뮤니티 기능(오늘의 옷장, 배틀 게시판) 등의 서비스를 제공합니다.
+• 서비스 내용은 필요에 따라 추가, 변경될 수 있으며, 이 경우 사전 공지합니다.
+
+제 4 조 이용자의 의무
+• 이용자는 본 APP을 불법적이거나 비도덕적인 목적으로 사용하지 않아야 합니다.
+• 타인의 권리를 침해하거나 명예를 훼손하는 콘텐츠를 업로드해서는 안 됩니다.
+• 타인의 정보를 도용하거나 APP의 정상적 운영을 방해하는 행위를 금지합니다.
+
+제 5 조 개인정보 보호
+• 개발자는 이용자의 개인정보를 보호하며, 수집, 이용, 제공에 대한 사항은 별도의 개인정보처리방침에 따릅니다.
+• 이용자가 업로드한 사진, 게시물 등은 서비스 제공 목적에 한해 사용됩니다.
+
+제 6 조 사용자 생성 콘텐츠
+• 이용자가 업로드한 모든 콘텐츠(사진, 댓글 등)는 이용자에게 저작권이 있으며, APP은 콘텐츠의 게시, 노출 및 서비스 운영 목적으로 이를 사용할 수 있습니다.
+• 부적절한 콘텐츠는 사전 통보 없이 삭제될 수 있습니다.
+• '오늘의 옷장'과 '배틀 게시판'에 업로드된 콘텐츠는 APP 내에서 순위 평가, 투표 등 커뮤니티 기능을 통해 활용될 수 있습니다.
+
+제 7 조 저작권 및 지적재산권
+• APP에서 제공되는 모든 자체 제작 콘텐츠에 대한 저작권은 개발자에게 있으며, 무단 복제, 배포를 금합니다.
+
+제 8 조 면책조항
+• 개발자는 천재지변, 기술적 문제 등으로 인한 서비스 중단 시 책임을 지지 않습니다.
+• 이용자가 업로드한 콘텐츠로 인한 분쟁에 대해 개발자는 책임을 지지 않습니다.
+
+제 9 조 계약 해지 및 이용 제한
+• 이용자는 언제든 APP 내 설정에서 탈퇴할 수 있습니다.
+• 약관 위반 시 사전 통보 없이 서비스 이용을 제한할 수 있습니다.
+
+제 10 조 약관 변경
+• 본 약관은 필요에 따라 변경될 수 있으며, 변경 시 APP 내 공지합니다.
+
+제 11 조 광고 및 마케팅
+• 개발자는 이용자에게 광고 및 마케팅 정보를 제공할 수 있으며, 이용자는 설정에서 수신 여부를 관리할 수 있습니다.
+
+제 12 조 문의처
+• 이메일: closit.support@gmail.com
\ No newline at end of file
diff --git a/app/src/main/ic_main-playstore.png b/app/src/main/ic_main-playstore.png
new file mode 100644
index 0000000..207ac6d
Binary files /dev/null and b/app/src/main/ic_main-playstore.png differ
diff --git a/app/src/main/java/com/example/umc_closit/ClositApp.kt b/app/src/main/java/com/example/umc_closit/ClositApp.kt
new file mode 100644
index 0000000..9667a75
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ClositApp.kt
@@ -0,0 +1,13 @@
+package com.example.umc_closit
+
+import android.app.Application
+import com.example.umc_closit.data.remote.RetrofitClient
+
+class ClositApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+
+ // 앱 시작할 때 Retrofit 초기화
+ RetrofitClient.init(applicationContext)
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/MainActivity.kt b/app/src/main/java/com/example/umc_closit/MainActivity.kt
index 7f6634d..aec2e6f 100644
--- a/app/src/main/java/com/example/umc_closit/MainActivity.kt
+++ b/app/src/main/java/com/example/umc_closit/MainActivity.kt
@@ -1,20 +1,14 @@
package com.example.umc_closit
import android.os.Bundle
-import androidx.activity.enableEdgeToEdge
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
+import androidx.activity.ComponentActivity
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-class MainActivity : AppCompatActivity() {
+class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
+ // 스플래시 화면 적용
+ installSplashScreen()
super.onCreate(savedInstanceState)
- enableEdgeToEdge()
setContentView(R.layout.activity_main)
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
- insets
- }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/BattleItem.kt b/app/src/main/java/com/example/umc_closit/data/entities/BattleItem.kt
new file mode 100644
index 0000000..47c4f8f
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/BattleItem.kt
@@ -0,0 +1,17 @@
+package com.example.umc_closit.data.entities
+
+// BattleItem: 배틀 게시글 데이터를 담는 데이터 클래스입니다.
+data class BattleItem(
+ val id: Int,
+ val battleId: Long,
+ val userName: String,
+ val userProfileUrl: String,
+ val leftPostId: Int,
+ val rightPostId: Int,
+ val battleLikeId: Int,
+ val leftPostImageUrl: String, // 추가
+ val rightPostImageUrl: String // 추가
+// var isLiked: Boolean = false // 좋아요 상태
+
+)
+
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/Comment.kt b/app/src/main/java/com/example/umc_closit/data/entities/Comment.kt
new file mode 100644
index 0000000..0d71668
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/Comment.kt
@@ -0,0 +1,16 @@
+package com.example.umc_closit.data.entities
+
+import java.io.Serializable
+
+data class Comment(
+ val commentId: Int,
+ var userInfo: UserInfo?, // 사용자 정보 (작성자 정보, 프로필 이미지 등)
+ var body: String, // 댓글 본문
+ var likeCount: Int = 0, // 좋아요 개수
+ val parentCommentId: Int?, // 상위 댓글 ID (답글 기능)
+ var userLike: Boolean, // 사용자 좋아요 상태
+ var createTime: String, // 댓글 생성 시간
+ var updateTime: String, // 댓글 수정 시간
+ var userName: String, // 사용자 이름
+ var commentText: String // 댓글 텍스트
+) : Serializable
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/HighlightItem.kt b/app/src/main/java/com/example/umc_closit/data/entities/HighlightItem.kt
new file mode 100644
index 0000000..59fba37
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/HighlightItem.kt
@@ -0,0 +1,3 @@
+package com.example.umc_closit.data.entities
+
+class HighlightItem(val imageResId: Int, val date: String)
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/Post.kt b/app/src/main/java/com/example/umc_closit/data/entities/Post.kt
new file mode 100644
index 0000000..e51a46c
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/Post.kt
@@ -0,0 +1,7 @@
+package com.example.umc_closit.data.entities
+
+data class Post (
+ val title: String, //글 제목
+ val imageUrl: String, // 게시글 위치
+ val postId: Int // 시연시 이미지 리소스 id 사용
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/RecentItem.kt b/app/src/main/java/com/example/umc_closit/data/entities/RecentItem.kt
new file mode 100644
index 0000000..0cb0196
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/RecentItem.kt
@@ -0,0 +1,6 @@
+package com.example.umc_closit.data.entities
+
+data class RecentItem(
+ val imageResId: Int,
+ val title: String // 예: "최근 항목 제목"
+)
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/TimelineItem.kt b/app/src/main/java/com/example/umc_closit/data/entities/TimelineItem.kt
new file mode 100644
index 0000000..a74a503
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/TimelineItem.kt
@@ -0,0 +1,25 @@
+package com.example.umc_closit.data.entities
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class TimelineItem(
+ val id: Int,
+ val mainImageResId: Int,
+ val overlayImageResId: Int,
+ val userProfileResId: Int,
+ val userName: String,
+ val likeCount: Int,
+ val commentCount: Int,
+ val isLiked: Boolean,
+ val isSaved: Boolean,
+ val postText: String,
+
+ val hashtags: List, // 추가된 필드
+
+ val uploadDate: String, // 업로드 날짜 (예: "2025-01-17T06:52:07.831513")
+ val pointColor: String // 포인트 색상 코드 (예: "#FF5733")
+
+) : Parcelable
+
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/UserInfo.kt b/app/src/main/java/com/example/umc_closit/data/entities/UserInfo.kt
new file mode 100644
index 0000000..f5e458d
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/UserInfo.kt
@@ -0,0 +1,7 @@
+package com.example.umc_closit.data.entities
+
+data class UserInfo(
+ val clositId: String, // 사용자 ID
+ val userName: String, // 사용자 이름
+ val userProfileImage: String? // 사용자 프로필 이미지 URL (옵션)
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/entities/post/TagData.kt b/app/src/main/java/com/example/umc_closit/data/entities/post/TagData.kt
new file mode 100644
index 0000000..fde5ac1
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/entities/post/TagData.kt
@@ -0,0 +1,11 @@
+package com.example.umc_closit.data.entities.post
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class TagData(
+ val xRatio: Float,
+ val yRatio: Float,
+ val tagText: String
+) : Parcelable
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/AuthInterceptor.kt b/app/src/main/java/com/example/umc_closit/data/remote/AuthInterceptor.kt
new file mode 100644
index 0000000..94f2337
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/AuthInterceptor.kt
@@ -0,0 +1,101 @@
+package com.example.umc_closit.data.remote
+
+import android.content.Context
+import android.util.Log
+import com.example.umc_closit.utils.TokenUtils
+import okhttp3.Interceptor
+import okhttp3.Response
+
+class AuthInterceptor(private val context: Context) : Interceptor {
+
+ companion object {
+ @Volatile private var isRefreshing = false
+ private val requestQueue = mutableListOf<() -> Unit>()
+ }
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ // 예외 URL은 그냥 통과
+ val excludedEndpoints = listOf("/api/auth/login", "/api/auth/register", "/api/auth/refresh")
+ if (excludedEndpoints.any { originalRequest.url.encodedPath.contains(it) }) {
+ return chain.proceed(originalRequest)
+ }
+
+ val accessToken = TokenUtils.getAccessToken(context) ?: ""
+ val authRequest = originalRequest.newBuilder()
+ .addHeader("Authorization", "Bearer $accessToken")
+ .build()
+
+ val response = chain.proceed(authRequest)
+
+ if (response.code != 401) return response
+
+ response.close()
+
+ // 401 → 재발급 로직 진입
+ synchronized(this) {
+ if (!isRefreshing) {
+ isRefreshing = true
+
+ // 실제 refresh 토큰 API 요청
+ val success = TokenUtils.refreshTokenSync(context)
+
+ isRefreshing = false
+
+ if (success) {
+ val newAccessToken = TokenUtils.getAccessToken(context) ?: ""
+ val previousToken = accessToken
+
+ Log.d("TOKEN_DEBUG", "🔎 이전 토큰: $previousToken")
+ Log.d("TOKEN_DEBUG", "🔎 새로 받은 토큰: $newAccessToken")
+
+ if (newAccessToken == previousToken) {
+ Log.e("TOKEN_DEBUG", "🚫 새 토큰이 이전 토큰과 동일 → 무한 루프 방지 차단")
+ return response
+ }
+
+ Log.d("TOKEN_DEBUG", "🔐 재발급 후 새로운 AccessToken으로 요청 → $newAccessToken")
+
+ val newRequest = originalRequest.newBuilder()
+ .removeHeader("Authorization")
+ .addHeader("Authorization", "Bearer $newAccessToken")
+ .build()
+
+ requestQueue.forEach { it() }
+ requestQueue.clear()
+
+ return chain.proceed(newRequest)
+ } else {
+ TokenUtils.moveToLogin(context)
+ return response // 재발급 실패
+ }
+ } else {
+ // 재발급 중이면 큐에 추가하고 기다림
+ val latch = java.util.concurrent.CountDownLatch(1)
+ var finalResponse: Response? = null
+
+ requestQueue.add {
+ val latestToken = TokenUtils.getAccessToken(context) ?: ""
+ Log.d("TOKEN_DEBUG", "🔁 재시도 요청에 최신 토큰 사용 → $latestToken")
+
+ val newRequest = originalRequest.newBuilder()
+ .removeHeader("Authorization")
+ .addHeader("Authorization", "Bearer $latestToken")
+ .build()
+
+ try {
+ finalResponse = chain.proceed(newRequest)
+ } catch (e: Exception) {
+ Log.e("TOKEN_DEBUG", "❌ 재시도 중 예외 발생: ${e.message}")
+ } finally {
+ latch.countDown()
+ }
+ }
+
+ latch.await()
+ return finalResponse ?: response
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/BaseResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/BaseResponse.kt
new file mode 100644
index 0000000..999e20f
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/BaseResponse.kt
@@ -0,0 +1,8 @@
+package com.example.umc_closit.data.remote
+
+data class BaseResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: T
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/RetrofitClient.kt b/app/src/main/java/com/example/umc_closit/data/remote/RetrofitClient.kt
new file mode 100644
index 0000000..3448a1c
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/RetrofitClient.kt
@@ -0,0 +1,81 @@
+package com.example.umc_closit.data.remote
+
+
+import ChallengeApiService
+import android.content.Context
+import android.util.Log
+import com.example.umc_closit.data.TodayClosetApiService
+import com.example.umc_closit.data.remote.auth.AuthService
+import com.example.umc_closit.data.remote.post.PostService
+import com.example.umc_closit.data.remote.profile.ProfileService
+import com.example.umc_closit.data.remote.timeline.TimelineService
+import com.example.umc_closit.data.remote.profile.history.HistoryService
+import com.example.umc_closit.data.remote.battle.BattleApiService
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+object RetrofitClient {
+ private const val BASE_URL = "https://closit.site/"
+
+ lateinit var retrofit: Retrofit
+
+ fun init(context: Context) {
+ // 로깅 인터셉터 추가
+ val loggingInterceptor = HttpLoggingInterceptor { message ->
+ Log.d("API_RESPONSE", message)
+ }.apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ val client = OkHttpClient.Builder()
+ .addInterceptor(AuthInterceptor(context))
+ .addInterceptor(loggingInterceptor) // 로깅 인터셉터 추가
+ .build()
+
+ retrofit = Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+ val authService: AuthService by lazy {
+ retrofit.create(AuthService::class.java)
+ }
+
+ val timelineService: TimelineService by lazy {
+ retrofit.create(TimelineService::class.java)
+ }
+
+ val profileService: ProfileService by lazy {
+ retrofit.create(ProfileService::class.java)
+ }
+
+ val battleApiService: BattleApiService by lazy {
+ retrofit.create(BattleApiService::class.java)
+ }
+ val todayClosetApiService: TodayClosetApiService by lazy {
+ retrofit.create(TodayClosetApiService::class.java)
+ }
+
+ val historyService: HistoryService by lazy {
+ retrofit.create(HistoryService::class.java)
+ }
+
+ val challengeApiService: ChallengeApiService by lazy {
+ retrofit.create(ChallengeApiService::class.java)
+ }
+
+ val postService: PostService by lazy{
+ retrofit.create(PostService::class.java)
+ }
+
+ fun createService(serviceClass: Class): T {
+ return retrofit.create(serviceClass)
+ }
+}
+
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/auth/AuthResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/auth/AuthResponse.kt
new file mode 100644
index 0000000..216a526
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/auth/AuthResponse.kt
@@ -0,0 +1,91 @@
+package com.example.umc_closit.data.remote.auth
+
+data class RegisterRequest(
+ val name: String,
+ val email: String,
+ val password: String,
+ val clositId: String,
+ val birth: String,
+ val profileImage: String
+)
+
+data class RegisterResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: RegisterResult?
+)
+
+data class RegisterResult(
+ val clositId: String,
+ val name: String,
+ val email: String
+)
+
+data class UserInfo(
+ val name: String,
+ val email: String
+)
+
+data class LoginRequest(
+ val clositId: String,
+ val password: String
+)
+
+data class LoginResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: LoginResult?
+)
+
+data class LoginResult(
+ val clositId: String, // 서버에서 string으로 내려옴
+ val accessToken: String,
+ val refreshToken: String
+)
+
+
+data class TokenResult(
+ val accessToken: String,
+ val refreshToken: String
+)
+
+// 중복 확인
+
+data class CheckIdResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: Boolean
+)
+
+
+// refresh
+
+data class RefreshRequest(
+ val refreshToken: String
+)
+
+
+data class RefreshResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: RefreshResult?
+)
+
+data class RefreshResult(
+ val clositId: String,
+ val accessToken: String,
+ val refreshToken: String
+)
+
+
+// quit
+data class QuitResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: T
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/auth/AuthService.kt b/app/src/main/java/com/example/umc_closit/data/remote/auth/AuthService.kt
new file mode 100644
index 0000000..046ae67
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/auth/AuthService.kt
@@ -0,0 +1,32 @@
+package com.example.umc_closit.data.remote.auth
+
+import com.example.umc_closit.data.remote.BaseResponse
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.POST
+import retrofit2.http.Path
+
+interface AuthService {
+ @POST("/api/auth/register")
+ fun registerUser(@Body request: RegisterRequest): Call
+
+ @POST("api/auth/login")
+ fun loginUser(@Body request: LoginRequest): Call
+
+ @POST("/api/auth/refresh")
+ fun refreshToken(
+ @Body request: RefreshRequest
+ ): Call
+
+ @GET("/api/v1/users/isunique/{closit_id}")
+ fun checkIdUnique(
+ @Path("closit_id") clositId: String
+ ): Call>
+
+ @DELETE("/api/v1/users/")
+ fun deleteUser(
+ ): Call>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleApiService.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleApiService.kt
new file mode 100644
index 0000000..48169d3
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleApiService.kt
@@ -0,0 +1,90 @@
+package com.example.umc_closit.data.remote.battle
+
+import CommentPostResult
+import CommentRequest
+import CommentResult
+import com.example.umc_closit.data.BattlePostRequest
+import com.example.umc_closit.data.BattlePostResponse
+import com.example.umc_closit.data.remote.BaseResponse
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface BattleApiService {
+ // 배틀 업로드 API
+ @Headers("Content-Type: application/json")
+ @POST("/api/v1/communities/battle/upload")
+ fun uploadBattle(
+ @Body request: BattlePostRequest
+ ): Call
+
+ // 배틀 vote API
+ @Headers("Content-Type: application/json")
+ @POST("/api/v1/communities/battle/{battle_id}/voting")
+ fun voteBattle(
+ @Path("battle_id") battleId: Long,
+ @Body requestBody: Map // {"postId": value}
+ ): Call
+
+ // 배틀 도전 API
+ @POST("/api/v1/communities/battle/{battle_id}/challenge/upload")
+ fun challengeBattle(
+ @Path("battle_id") battleId: Long,
+ @Body request: BattleChallengeRequest
+ ): Call
+
+ // 배틀 게시글 목록 조회 API
+ @GET("/api/v1/communities/battle")
+ fun getBattleList(
+ @Query("page") page: Int,
+ @Query("sorting") sorting: String = "LATEST", // "LATEST" or "TRENDING"
+ @Query("status") status: String = "ACTIVE" // "INACTIVE", "PENDING", "ACTIVE", "COMPLETED"
+ ): Call
+
+ // 배틀 챌린지 게시글 목록 조회 API
+ @GET("/api/v1/communities/battle/challenge")
+ fun getChallengeBattles(
+ @Query("page") page: Int
+ ): Call
+
+ // 배틀 게시글 삭제 API
+ @DELETE("/api/v1/communities/battle/{battle_id}")
+ fun deleteBattle(
+ @Path("battle_id") battleId: Long
+ ): Call
+
+ // 배틀 like API
+ @POST("/api/v1/communities/battle/like/{battleId}")
+ fun addBattleLike(@Path("battleId") battleId: Long): Call
+
+ // 배틀 like 취소 API
+ @DELETE("/api/v1/communities/battle/like/{battleLikeId}")
+ fun removeBattleLike(@Path("battleLikeId") battleLikeId: Int): Call
+
+ // 배틀 댓글 조회 API
+ @GET("/api/v1/communities/battle/{battle_id}/comments")
+ fun getBattleComments(
+ @Path("battle_id") battleId: Long,
+ @Query("page") page: Int
+ ): Call>
+
+ // 배틀 댓글 작성 API
+ @POST("/api/v1/communities/battle/{battle_id}/comments")
+ fun postBattleComment(
+ @Path("battle_id") battleId: Long,
+ @Body commentRequest: CommentRequest
+ ): Call>
+
+ // 배틀 댓글 삭제 API
+ @DELETE("/api/v1/communities/battle/{battle_id}/comments/{battle_comment_id}")
+ fun deleteBattleComment(
+ @Path("battle_id") battleId: Long,
+ @Path("battle_comment_id") battleCommentId: Int
+ ): Call>
+
+}
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleChallengeRequest.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleChallengeRequest.kt
new file mode 100644
index 0000000..c1d3d7a
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleChallengeRequest.kt
@@ -0,0 +1,8 @@
+package com.example.umc_closit.data.remote.battle
+
+import com.google.gson.annotations.SerializedName
+
+data class BattleChallengeRequest(
+ @SerializedName("postId") val postId: Int
+)
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleChallengeResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleChallengeResponse.kt
new file mode 100644
index 0000000..a6048b1
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleChallengeResponse.kt
@@ -0,0 +1,23 @@
+package com.example.umc_closit.data.remote.battle
+
+import com.google.gson.annotations.SerializedName
+
+data class BattleChallengeResponse(
+ @SerializedName("isSuccess") val isSuccess: Boolean,
+ @SerializedName("code") val code: String,
+ @SerializedName("message") val message: String,
+ @SerializedName("result") val result: BattleChallengeResult?
+)
+
+data class BattleChallengeResult(
+ @SerializedName("firstClositId") val firstClositId: String,
+ @SerializedName("firstPostId") val firstPostId: Int,
+ @SerializedName("firstPostFrontImage") val firstPostFrontImage: String,
+ @SerializedName("firstPostBackImage") val firstPostBackImage: String,
+ @SerializedName("secondClositId") val secondClositId: String,
+ @SerializedName("secondPostId") val secondPostId: Int,
+ @SerializedName("secondPostFrontImage") val secondPostFrontImage: String,
+ @SerializedName("secondPostBackImage") val secondPostBackImage: String,
+ @SerializedName("createdAt") val createdAt: String
+)
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleListResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleListResponse.kt
new file mode 100644
index 0000000..99e3ed1
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattleListResponse.kt
@@ -0,0 +1,37 @@
+package com.example.umc_closit.data.remote.battle
+
+import com.google.gson.annotations.SerializedName
+
+data class BattleListResponse(
+ @SerializedName("isSuccess") val isSuccess: Boolean,
+ @SerializedName("code") val code: String,
+ @SerializedName("message") val message: String,
+ @SerializedName("result") val result: BattleListResult?
+)
+
+data class BattleListResult(
+ @SerializedName("battlePreviewList") val battlePreviewList: List,
+ @SerializedName("listSize") val listSize: Int,
+ @SerializedName("hasNext") val hasNext: Boolean,
+ @SerializedName("first") val first: Boolean,
+ @SerializedName("last") val last: Boolean
+)
+
+data class BattlePreview(
+ @SerializedName("battleId") val battleId: Long,
+ @SerializedName("title") val title: String,
+ @SerializedName("firstClositId") val firstClositId: String,
+ @SerializedName("firstProfileImage") val firstProfileImage: String,
+ @SerializedName("firstPostId") val firstPostId: Int,
+ @SerializedName("firstPostFrontImage") val firstPostFrontImage: String,
+ @SerializedName("firstPostBackImage") val firstPostBackImage: String,
+ @SerializedName("firstVotingRate") val firstVotingRate: Float,
+ @SerializedName("secondClositId") val secondClositId: String,
+ @SerializedName("secondProfileImage") val secondProfileImage: String,
+ @SerializedName("secondPostId") val secondPostId: Int,
+ @SerializedName("secondPostFrontImage") val secondPostFrontImage: String,
+ @SerializedName("secondPostBackImage") val secondPostBackImage: String,
+ @SerializedName("secondVotingRate") val secondVotingRate: Float,
+ @SerializedName("liked") val liked: Boolean
+)
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/BattlePostRequest.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattlePostRequest.kt
new file mode 100644
index 0000000..731f4dd
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattlePostRequest.kt
@@ -0,0 +1,16 @@
+// BattlePostRequest.kt
+package com.example.umc_closit.data
+
+import com.google.gson.annotations.SerializedName
+
+data class BattlePostRequest(
+ @SerializedName("postId") val postId: Int,
+ @SerializedName("title") val title: String,
+ @SerializedName("description") val description: String
+)
+
+data class VoteRequest(
+ val postId: Int
+)
+
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/BattlePostResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattlePostResponse.kt
new file mode 100644
index 0000000..1bd83d1
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/BattlePostResponse.kt
@@ -0,0 +1,19 @@
+// BattlePostResponse.kt
+package com.example.umc_closit.data
+
+import com.google.gson.annotations.SerializedName
+
+data class BattlePostResponse(
+ @SerializedName("isSuccess") val isSuccess: Boolean,
+ @SerializedName("code") val code: String,
+ @SerializedName("message") val message: String,
+ @SerializedName("result") val result: BattlePostResult?
+)
+
+data class BattlePostResult(
+ @SerializedName("battleId") val battleId: Long,
+ @SerializedName("thumbnail") val thumbnail: String,
+ @SerializedName("deadline") val deadline: String,
+ @SerializedName("createdAt") val createdAt: String
+)
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/ChallengeBattleResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/ChallengeBattleResponse.kt
new file mode 100644
index 0000000..f990d17
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/ChallengeBattleResponse.kt
@@ -0,0 +1,62 @@
+package com.example.umc_closit.data.remote.battle
+
+import android.os.Parcel
+import android.os.Parcelable
+
+data class ChallengeBattleResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: ChallengeBattleResult?
+)
+
+data class ChallengeBattleResult(
+ val challengeBattlePreviewList: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+data class ChallengeBattlePreview(
+ val battleId: Long,
+ val firstClositId: String,
+ val firstProfileImage: String,
+ val firstPostId: Int,
+ val firstPostFrontImage: String,
+ val firstPostBackImage: String,
+ val title: String
+) : Parcelable {
+ constructor(parcel: Parcel) : this(
+ parcel.readLong(),
+ parcel.readString() ?: "",
+ parcel.readString() ?: "",
+ parcel.readInt(),
+ parcel.readString() ?: "",
+ parcel.readString() ?: "",
+ parcel.readString() ?: ""
+ )
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeLong(battleId)
+ parcel.writeString(firstClositId)
+ parcel.writeString(firstProfileImage)
+ parcel.writeInt(firstPostId)
+ parcel.writeString(firstPostFrontImage)
+ parcel.writeString(firstPostBackImage)
+ parcel.writeString(title)
+ }
+
+ override fun describeContents(): Int = 0
+
+ companion object CREATOR : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel): ChallengeBattlePreview {
+ return ChallengeBattlePreview(parcel)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/CommentResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/CommentResponse.kt
new file mode 100644
index 0000000..9afbf83
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/CommentResponse.kt
@@ -0,0 +1,27 @@
+data class CommentResult(
+ val battleCommentPreviewList: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+data class BattleComment(
+ val battleCommentId: Int,
+ val parentBattleCommentId: Long,
+ val clositId: String,
+ val thumbnail: String,
+ val content: String,
+ val createdAt: String
+)
+
+data class CommentRequest(
+ val content: String,
+ val parentCommentId: Long? = null
+)
+
+data class CommentPostResult(
+ val battleCommentId: Int,
+ val clositId: String,
+ val createdAt: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/DeleteBattleResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/DeleteBattleResponse.kt
new file mode 100644
index 0000000..9c9d78b
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/DeleteBattleResponse.kt
@@ -0,0 +1,8 @@
+package com.example.umc_closit.data.remote.battle
+
+data class DeleteBattleResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: String
+)
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/TodayClosetApiService.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/TodayClosetApiService.kt
new file mode 100644
index 0000000..83b4e5d
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/TodayClosetApiService.kt
@@ -0,0 +1,25 @@
+package com.example.umc_closit.data
+
+import com.example.umc_closit.data.remote.battle.TodayClosetResponse
+import com.example.umc_closit.data.remote.battle.TodayClosetUploadRequest
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.POST
+import retrofit2.http.Query
+
+interface TodayClosetApiService {
+ @GET("/api/v1/communities/todayclosets")
+ fun getTodayClosets(
+ @Query("page") page: Int
+ ): Call
+
+
+ @POST("/api/v1/communities/todayclosets")
+ fun uploadTodayCloset(
+ @Body request: TodayClosetUploadRequest
+ ): Call
+}
+
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/TodayClosetResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/TodayClosetResponse.kt
new file mode 100644
index 0000000..5c0e3a4
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/TodayClosetResponse.kt
@@ -0,0 +1,42 @@
+package com.example.umc_closit.data.remote.battle
+
+// 오늘의 옷장 요청 데이터
+data class TodayClosetResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: TodayClosetResult
+)
+
+data class TodayClosetResult(
+ val todayClosets: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+data class TodayClosetItem(
+ val todayClosetId: Int,
+ val postId: Int,
+ val frontImage: String,
+ val backImage: String,
+ val viewCount: Int,
+ val profileImage: String
+)
+data class TodayClosetUploadRequest(
+ val postId: Int
+)
+
+
+data class TodayClosetUploadResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: TodayClosetUploadResult
+)
+
+data class TodayClosetUploadResult(
+ val todayClosetId: Int,
+ val createdAt: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/battle/VoteResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/battle/VoteResponse.kt
new file mode 100644
index 0000000..47a7117
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/battle/VoteResponse.kt
@@ -0,0 +1,36 @@
+package com.example.umc_closit.data.remote.battle
+
+import com.google.gson.annotations.SerializedName
+
+// 서버에서 반환하는 투표 응답 데이터를 담는 클래스
+data class VoteResponse(
+ @SerializedName("isSuccess") val isSuccess: Boolean,
+ @SerializedName("code") val code: String,
+ @SerializedName("message") val message: String,
+ @SerializedName("result") val result: VoteResult?
+)
+
+// 투표 결과 데이터
+data class VoteResult(
+ @SerializedName("battleId") val battleId: Long,
+ @SerializedName("firstClositId") val firstClosetId: String,
+ @SerializedName("firstVotingRate") val firstVotingRate: Float,
+ @SerializedName("secondClositId") val secondClosetId: String,
+ @SerializedName("secondVotingRate") val secondVotingRate: Float,
+ @SerializedName("createdAt") val createdAt: String
+)
+
+
+// 좋아요 응답 데이터
+data class LikeResponse(
+ @SerializedName("isSuccess") val isSuccess: Boolean,
+ @SerializedName("code") val code: String,
+ @SerializedName("message") val message: String,
+ @SerializedName("result") val result: LikeResult?
+)
+
+// 좋아요 결과 데이터
+data class LikeResult(
+ @SerializedName("battleLikeId") val battleLikeId: Long,
+ @SerializedName("createdAt") val createdAt: String
+)
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/challenge/ChallengeApiService.kt b/app/src/main/java/com/example/umc_closit/data/remote/challenge/ChallengeApiService.kt
new file mode 100644
index 0000000..ffb9d33
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/challenge/ChallengeApiService.kt
@@ -0,0 +1,17 @@
+import com.example.umc_closit.data.remote.challenge.ChallengeRequest
+import com.example.umc_closit.data.remote.challenge.ChallengeResponse
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.Header
+import retrofit2.http.POST
+import retrofit2.http.Path
+
+// 챌린지 API 인터페이스
+interface ChallengeApiService {
+ @POST("/api/auth/communities/battle/challenge/upload/{battle_id}")
+ fun uploadChallenge(
+ @Header("Authorization") token: String, // 인증 헤더 추가
+ @Path("battle_id") battleId: Int, // 배틀 ID 경로 변수
+ @Body requestBody: ChallengeRequest // 요청 바디
+ ): Call
+}
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/challenge/ChallengeItem.kt b/app/src/main/java/com/example/umc_closit/data/remote/challenge/ChallengeItem.kt
new file mode 100644
index 0000000..25e4715
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/challenge/ChallengeItem.kt
@@ -0,0 +1,20 @@
+package com.example.umc_closit.data.remote.challenge
+
+data class ChallengeRequest(
+ val postId: Int
+)
+
+data class ChallengeResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: ChallengeResult?
+)
+
+data class ChallengeResult(
+ val firstClosItId: String,
+ val firstPostId: Int,
+ val secondClosItId: String,
+ val secondPostId: Int,
+ val createdAt: String
+)
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/post/PostResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/post/PostResponse.kt
new file mode 100644
index 0000000..324112e
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/post/PostResponse.kt
@@ -0,0 +1,99 @@
+package com.example.umc_closit.data.remote.post
+
+data class PostResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: PostDetail
+)
+
+data class PostDetail(
+ val postId: Int,
+ val clositId: String,
+ val username: String,
+ val profileImage: String,
+ val frontImage: String,
+ val backImage: String,
+ val isLiked: Boolean,
+ val isSaved: Boolean,
+ val isHighlighted: Boolean,
+ val hashtags: List,
+ val frontItemtags: List,
+ val backItemtags: List,
+ val pointColor: String,
+ val visibility: String,
+ val mission: Boolean
+)
+
+data class PostDeleteResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: Map = emptyMap() // 빈 객체
+)
+
+data class ItemTag(
+ val x: Float,
+ val y: Float,
+ val content: String
+)
+
+data class PostRequest(
+ val frontImage: String,
+ val backImage: String,
+ val hashtags: List,
+ val frontItemtags: List,
+ val backItemtags: List,
+ val pointColor: String,
+ val visibility: String,
+ val mission: Boolean
+)
+
+data class PostUploadResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: PostUploadResult
+)
+
+data class PostUploadResult(
+ val clositId: String,
+ val postId: Int,
+ val createdAt: String,
+ val visibility: String
+)
+data class RecentPostResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: RecentPostResult
+)
+
+data class RecentPostResult(
+ val userRecentPostDTOList: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+data class UserRecentPostDTO(
+ val clositId: String,
+ val userName: String,
+ val postId: Int,
+ val thumbnail: String,
+ val createdAt: String
+)
+
+// 이미지처리
+data class PresignedUrlResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: PresignedUrlResult
+)
+
+data class PresignedUrlResult(
+ val frontImageUrl: String,
+ val backImageUrl: String
+)
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/post/PostService.kt b/app/src/main/java/com/example/umc_closit/data/remote/post/PostService.kt
new file mode 100644
index 0000000..faed86a
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/post/PostService.kt
@@ -0,0 +1,43 @@
+package com.example.umc_closit.data.remote.post
+
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.Call
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.DELETE
+import retrofit2.http.Body
+import retrofit2.http.Multipart
+import retrofit2.http.Part
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface PostService {
+ @GET("/api/v1/posts/{post_id}")
+ fun getPostDetail(
+ @Path("post_id") postId: Int
+ ): Call
+
+ @DELETE("/api/v1/posts/{post_id}")
+ suspend fun deletePost(
+ @Path("post_id") postId: Int
+ ): Response
+
+ @POST("/api/v1/posts")
+ suspend fun uploadPost(
+ @Body request: PostRequest
+ ): Response
+
+ @GET("/api/v1/users/{closit_id}/recent-post")
+ fun getRecentPosts(
+ @Path("closit_id") clositId: String,
+ @Query("page") page: Int
+ ): Call
+
+ @POST("/api/v1/posts/presigned-url")
+ suspend fun getPresignedUrls(
+ @Body request: RequestBody
+ ): PresignedUrlResponse
+}
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/post/TagData.kt b/app/src/main/java/com/example/umc_closit/data/remote/post/TagData.kt
new file mode 100644
index 0000000..791cdec
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/post/TagData.kt
@@ -0,0 +1,11 @@
+package com.example.umc_closit.data.remote.post
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class TagData(
+ val xRatio: Float,
+ val yRatio: Float,
+ val tagText: String
+) : Parcelable
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/profile/ProfileResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/profile/ProfileResponse.kt
new file mode 100644
index 0000000..e487dac
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/profile/ProfileResponse.kt
@@ -0,0 +1,255 @@
+package com.example.umc_closit.data.remote.profile
+
+// follow
+data class FollowRequest(
+ val receiverClositId: String
+)
+
+data class FollowResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: FollowResult?
+)
+
+data class FollowResult(
+ val followId: Int,
+ val senderId: Int,
+ val receiverId: Int,
+ val createdAt: String
+)
+
+// unfollow
+data class UnfollowResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: String
+)
+
+// isfollowing
+data class FollowCheckResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: Boolean
+)
+
+// profile info
+data class ProfileUserResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: ProfileUserResult
+)
+
+data class ProfileUserResult(
+ val role: String,
+ val clositId: String,
+ val name: String,
+ val email: String,
+ val birth: String,
+ val profileImage: String?,
+ val followers: Int,
+ val following: Int,
+ val createdAt: String
+)
+
+// edit profile
+data class EditProfileRequest(
+ val name: String,
+ val currentPassword: String,
+ val password: String,
+ val birth: String
+)
+
+data class PresignedProfileUrlResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: ProfilePresignedUrlResult
+)
+
+data class ProfilePresignedUrlResult(
+ val imageUrl: String
+)
+
+data class ProfileImageUploadResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: UserProfileData
+)
+
+data class UserProfileData(
+ val role: String,
+ val clositId: String,
+ val name: String,
+ val email: String,
+ val birth: String,
+ val profileImage: String
+)
+
+// highlight
+data class HighlightListResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: HighlightListResult
+)
+
+data class HighlightListResult(
+ val highlights: List,
+ val hasNext: Boolean,
+ val pageNumber: Int,
+ val size: Int
+)
+
+data class HighlightItem(
+ val clositId: String,
+ val userName: String,
+ val postId: Int,
+ val thumbnail: String,
+ val createdAt: String,
+ val updatedAt: String? = null
+)
+
+data class HighlightDetailResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: HighlightDetailResult
+)
+
+data class HighlightDetailResult(
+ val highlightId: Int,
+ val clositId: String,
+ val createdAt: String,
+ val updatedAt: String?,
+ val post: HighlightPost
+)
+
+data class HighlightPost(
+ val id: Int,
+ val backImage: String,
+ val createdAt: String
+)
+
+
+data class HighlightCreateResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: HighlightCreateResult
+)
+
+data class HighlightCreateResult(
+ val highlightId: Int,
+ val clositId: String,
+ val postId: Int,
+ val createdAt: String
+)
+
+data class HighlightDeleteResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: String
+)
+
+// 북마크 조회
+data class BookmarkResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: BookmarkResult
+)
+
+data class BookmarkResult(
+ val bookmarkResultDTOList: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+data class BookmarkItem(
+ val clositId: String,
+ val userName: String,
+ val bookmarkId: Int,
+ val postId: Int,
+ val thumbnail: String,
+ val createdAt: String
+)
+
+
+// 팔로잉 목록
+
+data class FollowingResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: FollowingResult
+)
+
+data class FollowingResult(
+ val followings: List,
+ val hasNext: Boolean
+)
+
+// 팔로워 목록
+data class FollowerResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: FollowerResult
+)
+
+data class FollowerResult(
+ val followers: List,
+ val hasNext: Boolean
+)
+
+data class Follow(
+ val clositId: String,
+ val name: String,
+ val email: String,
+ val birth: String,
+ val profileImage: String
+)
+
+// 차단된 사용자
+data class BlockedUser(
+ val clositId: String,
+ val name: String,
+ val profileImage: String
+)
+
+data class BlockedUserListResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: BlockedUserListResult
+)
+
+data class BlockedUserListResult(
+ val blockedUsers: List,
+ val hasNext: Boolean,
+ val pageNumber: Int,
+ val size: Int
+)
+
+data class BlockRequest(
+ val blockedClositId: String
+)
+
+data class BlockStatusResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: BlockStatusResult
+)
+
+data class BlockStatusResult(
+ val blocked: Boolean
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/profile/ProfileService.kt b/app/src/main/java/com/example/umc_closit/data/remote/profile/ProfileService.kt
new file mode 100644
index 0000000..9702bba
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/profile/ProfileService.kt
@@ -0,0 +1,116 @@
+package com.example.umc_closit.data.remote.profile
+
+import com.example.umc_closit.data.remote.BaseResponse
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.Multipart
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.Part
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface ProfileService {
+
+ // follow
+ @POST("/api/v1/follows")
+ fun followUser(
+ @Body request: FollowRequest
+ ): Call
+
+ @DELETE("/api/v1/follows/{receiver_closit_id}")
+ fun unfollowUser(
+ @Path("receiver_closit_id") receiverClositId: String
+ ): Call
+
+ // check follow
+ @GET("/api/v1/follows/{receiver_closit_id}")
+ fun checkFollowStatus(
+ @Path("receiver_closit_id") receiverClositId: String
+ ): Call
+
+ // profile info
+ @GET("/api/v1/users/{closit_id}")
+ fun getUserProfile(
+ @Path("closit_id") clositId: String
+ ): Call
+
+ @PATCH("/api/v1/users/profile-image")
+ fun uploadProfileImage(
+ @Body request: RequestBody
+ ): Call
+
+ @PATCH("/api/v1/users/")
+ fun updateUserProfile(
+ @Body request: EditProfileRequest
+ ): Call
+
+ @POST("/api/v1/users/profile-image/presigned-url")
+ fun getPresignedProfileUrl(@Body body: RequestBody): Call
+
+ // highlight
+ @GET("/api/v1/users/{closit_id}/highlights")
+ fun getHighlights(
+ @Path("closit_id") clositId: String,
+ @Query("page") page: Int = 0,
+ @Query("size") size: Int = 10
+ ): Call
+
+ @GET("/api/v1/highlights/{highlight_id}")
+ fun getHighlightDetail(
+ @Path("highlight_id") highlightId: Int
+ ): Call
+
+ @POST("/api/v1/highlights")
+ fun createHighlight(
+ @Body postId: Map
+ ): Call
+
+ @DELETE("/api/v1/highlights/{post_id}") // 주의!
+ fun deleteHighlight(
+ @Path("post_id") highlightId: Int
+ ): Call
+
+ @GET("/api/v1/bookmarks")
+ fun getBookmarks(
+ @Query("page") page: Int = 0, // 기본값 0
+ @Query("size") size: Int = 10 // 기본값 10
+ ): Call
+
+ // following list
+ @GET("/api/v1/users/{closit_id}/following")
+ fun getFollowingList(
+ @Path("closit_id") clositId: String, // 사용자 ID
+ @Query("page") page: Int, // 페이지 번호
+ @Query("size") size: Int // 한 페이지에 불러올 항목 수
+ ): Call
+
+ // follower list
+ @GET("/api/v1/users/{closit_id}/followers")
+ fun getFollowersList(
+ @Path("closit_id") clositId: String, // 사용자 ID
+ @Query("page") page: Int, // 페이지 번호
+ @Query("size") size: Int // 한 페이지에 불러올 항목 수
+ ): Call
+
+ // block
+ @GET("/api/v1/users/block")
+ fun getBlockedUsers(): Call
+
+ @POST("/api/v1/users/block")
+ fun blockUser(@Body body: BlockRequest): Call>
+
+ @DELETE("/api/v1/users/block")
+ fun unblockUser(@Body body: BlockRequest): Call>
+
+ @GET("/api/v1/users/block")
+ fun checkUserBlocked(
+ @Query("closit_id") clositId: String
+ ): Call
+
+}
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/profile/history/HistoryResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/profile/history/HistoryResponse.kt
new file mode 100644
index 0000000..3892a96
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/profile/history/HistoryResponse.kt
@@ -0,0 +1,64 @@
+package com.example.umc_closit.data.remote.profile.history
+
+// history
+data class DateHistoryResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: DateHistoryResult
+)
+
+data class DateHistoryResult(
+ val dateHistoryThumbnailDTOList: List?,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+
+data class DateHistoryThumbnail(
+ val postId: Int,
+ val thumbnail: String,
+ val createdAt: String
+)
+
+//color
+data class ColorHistoryResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: ColorHistoryResult
+)
+
+data class ColorHistoryResult(
+ val colorHistoryThumbnailDTOList: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+data class ColorHistoryThumbnail(
+ val postId: Int,
+ val thumbnail: String,
+ val createdAt: String
+)
+
+// detail
+data class DetailHistoryResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: DetailHistoryResult
+)
+
+data class DetailHistoryResult(
+ val postList: List,
+ val date: String
+)
+
+data class DetailHistoryPost(
+ val postId: Int,
+ val createdAt: String
+)
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/profile/history/HistoryService.kt b/app/src/main/java/com/example/umc_closit/data/remote/profile/history/HistoryService.kt
new file mode 100644
index 0000000..d119364
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/profile/history/HistoryService.kt
@@ -0,0 +1,16 @@
+package com.example.umc_closit.data.remote.profile.history
+
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface HistoryService {
+ @GET("/api/v1/history")
+ fun getDateHistoryList(@Query("page") page: Int): Call
+
+ @GET("/api/v1/history/pointcolor")
+ fun getPointColorHistoryList(@Query("page") page: Int): Call
+
+ @GET("/api/v1/history/detail")
+ fun getDetailHistory(@Query("localDate") date: String): Call
+}
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/timeline/TimelineResponse.kt b/app/src/main/java/com/example/umc_closit/data/remote/timeline/TimelineResponse.kt
new file mode 100644
index 0000000..8121393
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/timeline/TimelineResponse.kt
@@ -0,0 +1,183 @@
+package com.example.umc_closit.data.remote.timeline
+
+import android.os.Parcelable
+import kotlinx.android.parcel.Parcelize
+import kotlinx.android.parcel.RawValue
+
+data class TimelineResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: TimelineResult
+)
+
+data class TimelineResult(
+ val postPreviewList: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+// Hashtag 데이터 클래스 추가
+@Parcelize
+data class Hashtag(
+ val id: Int? = null,
+ val name: String? = null,
+ val content: String? = null
+) : Parcelable
+
+// Timeline item
+@Parcelize
+data class PostPreview(
+ val postId: Int,
+ val clositId: String,
+ val profileImage: String,
+ val frontImage: String,
+ val backImage: String,
+ val isLiked: Boolean,
+ val isSaved: Boolean,
+ val isFriend: Boolean,
+ val likeCount: Int,// 좋아요 수 세기위한 필드 추가
+ val hashtags: List,
+ val frontItemtags: List,
+ val backItemtags: List,
+ val pointColor: String,
+ val visibility: String
+): Parcelable
+
+@Parcelize
+data class Hashtag(
+ val id: Long,
+ val name: String
+) : Parcelable
+
+@Parcelize
+data class ItemTag(
+ val x: Float,
+ val y: Float,
+ val content: String
+) : Parcelable
+
+// like
+data class LikeResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: LikeResult
+)
+
+data class LikeResult(
+ val isLiked: Boolean,
+ val postId: Int,
+ val clositId: String
+)
+
+// save
+data class BookmarkRequest(
+ val postId: Int
+)
+
+data class BookmarkCreateResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: BookmarkCreateResult
+)
+
+data class BookmarkCreateResult(
+ val clositId: String,
+ val userName: String,
+ val bookmarkId: Int,
+ val postId: Int,
+ val createdAt: String
+)
+
+data class BookmarkDeleteResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: String
+)
+
+// notification
+data class NotificationResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: NotificationResult
+)
+
+data class NotificationResult(
+ val notiPreviewDTOList: List,
+ val listSize: Int,
+ val hasNext: Boolean,
+ val first: Boolean,
+ val last: Boolean
+)
+
+data class NotificationItem(
+ val notificationId: Int,
+ val clositId: String,
+ val userName: String,
+ val imageUrl: String,
+ val content: String,
+ val type: String,
+ val read: Boolean
+)
+
+data class NotificationReadResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: NotificationItem
+)
+
+data class NotificationDeleteResponse(
+ val isSuccess: Boolean,
+ val code: String,
+ val message: String,
+ val result: String
+)
+
+// Comment
+data class CommentListResponse(
+ val isSuccess: Boolean,
+ val result: CommentListResult
+)
+
+data class CommentListResult(
+ val commentPreviewList: List,
+ val hasNext: Boolean
+)
+
+data class CommentItem(
+ val commentId: Int,
+ val clositId: String,
+ val content: String,
+ val createdAt: String,
+ var name: String? = null, // 추가
+ var profileImage: String? = null // 추가
+)
+
+
+data class CommentRequest(
+ val content: String
+)
+
+data class CommentCreateResponse(
+ val isSuccess: Boolean,
+ val result: CommentCreateResult
+)
+
+data class CommentCreateResult(
+ val commentId: Int,
+ val clositId: String,
+ val createdAt: String
+)
+
+data class CommentDeleteResponse(
+ val isSuccess: Boolean,
+ val result: String
+)
+
diff --git a/app/src/main/java/com/example/umc_closit/data/remote/timeline/TimelineService.kt b/app/src/main/java/com/example/umc_closit/data/remote/timeline/TimelineService.kt
new file mode 100644
index 0000000..8cf9696
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/data/remote/timeline/TimelineService.kt
@@ -0,0 +1,82 @@
+package com.example.umc_closit.data.remote.timeline
+
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface TimelineService {
+
+ // timeline
+ @GET("api/v1/posts")
+ fun getPosts(
+ @Query("follower") follower: Boolean = false,
+ @Query("page") page: Int = 0,
+ @Query("size") size: Int = 10,
+ @Query("sort") sort: String = "LATEST"
+ ): Call
+
+ // like
+ @POST("/api/v1/posts/{post_id}/likes")
+ fun addLike(
+ @Path("post_id") postId: Int
+ ): Call
+
+ @DELETE("/api/v1/posts/{post_id}/likes")
+ fun removeLike(
+ @Path("post_id") postId: Int
+ ): Call
+
+ // bookmark
+ @POST("/api/v1/bookmarks")
+ fun addBookmark(
+ @Body request: BookmarkRequest
+ ): Call
+
+ @DELETE("/api/v1/bookmarks/{post_id}")
+ fun removeBookmark(
+ @Path("post_id") postId: Int
+ ): Call
+
+ // notification
+ @PATCH("/api/v1/notifications")
+ fun getNotifications(
+ @Query("page") page: Int
+ ): Call
+
+
+ @PATCH("/api/v1/notifications/{notification_id}")
+ fun readNotification(
+ @Path("notification_id") notificationId: Int
+ ): Call
+
+ @DELETE("/api/v1/notifications/{notification_id}")
+ fun deleteNotification(
+ @Path("notification_id") notificationId: Int
+ ): Call
+
+ // comments
+ @GET("/api/v1/posts/{post_id}/comments")
+ fun getComments(
+ @Path("post_id") postId: Int,
+ @Query("page") page: Int
+ ): Call
+
+ @POST("/api/v1/posts/{post_id}/comments")
+ fun postComment(
+ @Path("post_id") postId: Int,
+ @Body content: CommentRequest
+ ): Call
+
+ @DELETE("/api/v1/posts/{post_id}/comments/{comment_id}")
+ fun deleteComment(
+ @Path("post_id") postId: Int,
+ @Path("comment_id") commentId: Int
+ ): Call
+
+}
diff --git a/app/src/main/java/com/example/umc_closit/model/BattleViewModel.kt b/app/src/main/java/com/example/umc_closit/model/BattleViewModel.kt
new file mode 100644
index 0000000..7147cd9
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/model/BattleViewModel.kt
@@ -0,0 +1,127 @@
+package com.example.umc_closit.data
+
+import BattleComment
+import CommentPostResult
+import CommentRequest
+import CommentResult
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.example.umc_closit.data.remote.BaseResponse
+import com.example.umc_closit.data.remote.RetrofitClient
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+// BattleViewModel: 배틀 관련 상태 관리를 담당하는 ViewModel입니다.
+class BattleViewModel: ViewModel() {
+ // 좋아요 상태 관리
+ private val likedPosts = mutableMapOf()
+
+ fun getLikeStatus(postId: Int): Boolean? = likedPosts[postId]
+
+ fun updateLikeStatus(postId: Int, isLiked: Boolean) {
+ likedPosts[postId] = isLiked
+ }
+
+ // 댓글 리스트 LiveData
+ private val _comments = MutableLiveData>()
+ val comments: LiveData> get() = _comments
+
+ // 댓글 작성 성공 여부
+ private val _isCommentPosted = MutableLiveData()
+ val isCommentPosted: LiveData get() = _isCommentPosted
+
+ // 댓글 삭제 성공 여부
+ private val _isCommentDeleted = MutableLiveData()
+ val isCommentDeleted: LiveData get() = _isCommentDeleted
+
+ // 로딩 상태
+ private val _isLoading = MutableLiveData()
+ val isLoading: LiveData get() = _isLoading
+
+ // 에러 메시지
+ private val _errorMessage = MutableLiveData()
+ val errorMessage: LiveData get() = _errorMessage
+
+ private val battleApiService = RetrofitClient.battleApiService
+
+ // 댓글 조회 메서드
+ fun fetchComments(battleId: Long, page: Int) {
+ _isLoading.value = true
+
+ battleApiService.getBattleComments(battleId, page)
+ .enqueue(object : Callback> {
+ override fun onResponse(
+ call: Call>,
+ response: Response>
+ ) {
+ _isLoading.value = false
+ if (response.isSuccessful && response.body()?.isSuccess == true) {
+ _comments.value = response.body()?.result?.battleCommentPreviewList ?: emptyList()
+ } else {
+ _errorMessage.value = "댓글 불러오기 실패: ${response.body()?.message}"
+ }
+ }
+
+ override fun onFailure(call: Call>, t: Throwable) {
+ _isLoading.value = false
+ _errorMessage.value = "네트워크 오류: ${t.message}"
+ }
+ })
+ }
+
+ // 댓글 작성 메서드
+ fun postComment(battleId: Long, content: String, parentCommentId: Long? = null) {
+ val commentRequest = CommentRequest(content, parentCommentId ?: 0L)
+
+ battleApiService.postBattleComment(battleId, commentRequest)
+ .enqueue(object : Callback> {
+ override fun onResponse(
+ call: Call>,
+ response: Response>
+ ) {
+ if (response.isSuccessful && response.body()?.isSuccess == true) {
+ _isCommentPosted.value = true
+ fetchComments(battleId, page = 0)
+ } else {
+ _isCommentPosted.value = false
+ _errorMessage.value = "댓글 작성 실패: ${response.body()?.message}"
+ }
+ }
+
+ override fun onFailure(call: Call>, t: Throwable) {
+ _isCommentPosted.value = false
+ _errorMessage.value = "네트워크 오류: ${t.message}"
+ }
+ })
+ }
+
+ // 댓글 삭제 메서드
+ fun deleteComment(battleId: Long, commentId: Int) {
+ _isLoading.value = true
+
+ battleApiService.deleteBattleComment(battleId, commentId)
+ .enqueue(object : Callback> {
+ override fun onResponse(
+ call: Call>,
+ response: Response>
+ ) {
+ _isLoading.value = false
+ if (response.isSuccessful && response.body()?.isSuccess == true) {
+ _isCommentDeleted.value = true
+ fetchComments(battleId, page = 0)
+ } else {
+ _isCommentDeleted.value = false
+ _errorMessage.value = "댓글 삭제 실패: ${response.body()?.message}"
+ }
+ }
+
+ override fun onFailure(call: Call>, t: Throwable) {
+ _isLoading.value = false
+ _isCommentDeleted.value = false
+ _errorMessage.value = "네트워크 오류: ${t.message}"
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/model/PostViewModel.kt b/app/src/main/java/com/example/umc_closit/model/PostViewModel.kt
new file mode 100644
index 0000000..bb9fe57
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/model/PostViewModel.kt
@@ -0,0 +1,89 @@
+package com.example.umc_closit.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.post.PostRequest
+import com.example.umc_closit.data.remote.post.PostService
+import com.example.umc_closit.data.remote.post.PostUploadResponse
+import kotlinx.coroutines.launch
+import okhttp3.RequestBody
+
+class PostViewModel : ViewModel() {
+
+ private val postService: PostService = RetrofitClient.postService
+
+ // 업로드 결과를 위한 LiveData
+ private val _uploadResult = MutableLiveData>()
+ val uploadResult: LiveData> = _uploadResult
+
+ fun uploadPost(
+ requestBody: PostRequest,
+ ) {
+ viewModelScope.launch {
+ try {
+ // API 호출
+ val response = postService.uploadPost(
+ request = requestBody
+ )
+
+ // 응답 처리
+ if (response.isSuccessful && response.body()?.isSuccess == true) {
+ _uploadResult.postValue(Result.success(response.body()!!))
+ println("업로드 성공: ${response.body()?.result?.postId}")
+ } else {
+ _uploadResult.postValue(
+ Result.failure(Exception("업로드 실패: ${response.body()?.message ?: response.message()}"))
+ )
+ }
+ } catch (e: Exception) {
+ _uploadResult.postValue(Result.failure(e))
+ println("에러 발생: ${e.message}")
+ }
+ }
+ }
+}
+
+ /*
+ // Multipart 업로드 함수
+ fun uploadPost(
+ frontImage: MultipartBody.Part,
+ backImage: MultipartBody.Part,
+ hashtags: List,
+ frontItemtags: List,
+ backItemtags: List,
+ pointColor: String,
+ visibility: String,
+ mission: Boolean
+ ) {
+ viewModelScope.launch {
+ try {
+ val response = postService.uploadPost(
+ frontImage = frontImage,
+ backImage = backImage,
+ hashtags = hashtags,
+ frontItemtags = frontItemtags,
+ backItemtags = backItemtags,
+ pointColor = pointColor,
+ visibility = visibility,
+ mission = mission
+ )
+ if (response.isSuccessful && response.body()?.isSuccess == true) {
+ _uploadResult.postValue(Result.success(response.body()!!))
+ } else {
+ _uploadResult.postValue(
+ Result.failure(
+ Exception("업로드 실패: ${response.body()?.message ?: response.message()}")
+ )
+ )
+ }
+ } catch (e: Exception) {
+ _uploadResult.postValue(Result.failure(e))
+ }
+ }
+ }
+}
+
+ */
diff --git a/app/src/main/java/com/example/umc_closit/model/TimelineViewModel.kt b/app/src/main/java/com/example/umc_closit/model/TimelineViewModel.kt
new file mode 100644
index 0000000..25020a8
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/model/TimelineViewModel.kt
@@ -0,0 +1,96 @@
+package com.example.umc_closit.model
+
+import android.content.Context
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.timeline.PostPreview
+import com.example.umc_closit.data.remote.timeline.TimelineResponse
+import com.example.umc_closit.utils.TokenUtils
+
+class TimelineViewModel : ViewModel() {
+ private val _timelineItems = MutableLiveData?>()
+ val timelineItems: LiveData?> get() = _timelineItems
+
+ private val _isLoading = MutableLiveData()
+ val isLoading: LiveData get() = _isLoading
+
+ var currentPage = 0
+ var hasNextPage = true
+
+ fun fetchTimelinePosts(context: Context) {
+ if (_isLoading.value == true || !hasNextPage) {
+ Log.d("TimelineViewModel", "⛔ 요청 차단됨: isLoading=${_isLoading.value}, hasNextPage=$hasNextPage")
+ return
+ }
+
+ Log.d("TimelineViewModel", "🚀 fetchTimelinePosts 시작 - currentPage=$currentPage")
+ _isLoading.value = true
+ Log.d("TIMELINE_DEBUG", "🔄 타임라인 데이터 요청 시작 - 페이지: $currentPage")
+
+ val apiCall = {
+ Log.d("TimelineViewModel", "📡 API 요청 준비 완료 - page=$currentPage")
+ RetrofitClient.timelineService.getPosts(page = currentPage, size = 10)
+ }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+
+ Log.d("TIMELINE_DEBUG", "✅ 타임라인 API 응답 성공")
+ Log.d("TIMELINE_DEBUG", "📊 응답 데이터: $response")
+ Log.d("TIMELINE_DEBUG", "📋 isSuccess: ${response.isSuccess}")
+ Log.d("TIMELINE_DEBUG", "📋 code: ${response.code}")
+ Log.d("TIMELINE_DEBUG", "📋 message: ${response.message}")
+
+ if (response.isSuccess) {
+ Log.d("TIMELINE_DEBUG", "📋 result.postPreviewList 크기: ${response.result.postPreviewList.size}")
+ Log.d("TIMELINE_DEBUG", "📋 result.hasNext: ${response.result.hasNext}")
+ Log.d("TIMELINE_DEBUG", "📋 result.listSize: ${response.result.listSize}")
+
+ // 각 아이템의 데이터 확인
+ response.result.postPreviewList.forEachIndexed { index, item ->
+ Log.d("TIMELINE_DEBUG", "📋 아이템[$index] postId: ${item.postId}")
+ Log.d("TIMELINE_DEBUG", "📋 아이템[$index] clositId: ${item.clositId}")
+ Log.d("TIMELINE_DEBUG", "📋 아이템[$index] frontImage: ${item.frontImage}")
+ Log.d("TIMELINE_DEBUG", "📋 아이템[$index] backImage: ${item.backImage}")
+ }
+
+ val newItems = response.result.postPreviewList.filterNotNull()
+ Log.d("TIMELINE_DEBUG", "📋 필터링된 아이템 수: ${newItems.size}")
+
+ val currentList = _timelineItems.value.orEmpty().toMutableList()
+ currentList.addAll(newItems)
+ _timelineItems.value = currentList
+
+ hasNextPage = response.result?.hasNext ?: false
+ Log.d("TimelineViewModel", "📄 hasNextPage: $hasNextPage")
+ currentPage++
+
+ Log.d("TIMELINE_DEBUG", "📋 현재 총 아이템 수: ${currentList.size}")
+ Log.d("TIMELINE_DEBUG", "📋 다음 페이지 존재: $hasNextPage")
+ Log.d("TIMELINE_DEBUG", "📋 현재 페이지: $currentPage")
+ } else {
+ Log.e("TIMELINE_DEBUG", "❌ API 응답 실패: ${response.message}")
+ }
+
+ _isLoading.value = false
+ },
+ onFailure = { error ->
+ Log.e("TIMELINE_DEBUG", "❌ 타임라인 API 요청 실패: ${error.message}")
+ Log.e("TIMELINE_DEBUG", "❌ 에러 상세: ", error)
+ _isLoading.value = false
+ },
+ context = context
+ )
+ }
+
+ fun resetPage() {
+ Log.d("TIMELINE_DEBUG", "🔄 페이지 초기화")
+ currentPage = 0
+ hasNextPage = true
+ _timelineItems.value = emptyList()
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/CommunityFragment.kt b/app/src/main/java/com/example/umc_closit/ui/community/CommunityFragment.kt
new file mode 100644
index 0000000..b367eda
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/CommunityFragment.kt
@@ -0,0 +1,100 @@
+package com.example.umc_closit.ui.community
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import com.example.umc_closit.R
+import com.example.umc_closit.databinding.FragmentCommunityBinding
+import com.example.umc_closit.ui.community.battle.BattleFragment
+import com.example.umc_closit.ui.community.battle.NewBattleActivity
+import com.example.umc_closit.ui.community.challenge.ChallengeFragment
+import com.example.umc_closit.ui.community.todaycloset.TodayClosetFragment
+import com.example.umc_closit.ui.timeline.detail.DetailActivity
+import com.example.umc_closit.ui.upload.UploadActivity
+
+class CommunityFragment : Fragment() {
+
+ private var _binding: FragmentCommunityBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentCommunityBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ android.util.Log.d("CommunityFragment", "CommunityFragment onViewCreated 호출됨")
+
+ // "오늘의 옷장" 버튼 클릭 시 fragment_todaycloset으로 변경
+ binding.btnTodaycloset.setOnClickListener {
+ val fragmentTransaction = parentFragmentManager.beginTransaction()
+ fragmentTransaction.replace(R.id.fragment_container, TodayClosetFragment()) // fragment_todaycloset.xml을 로드하는 Fragment
+ fragmentTransaction.addToBackStack(null) // 뒤로 가기 가능하도록 설정
+ fragmentTransaction.commit()
+ }
+
+ // imgDetail1 버튼 클릭 시 DetailActivity 실행
+ binding.imgDetail1.setOnClickListener {
+ val intent = Intent(requireContext(), DetailActivity::class.java)
+ startActivity(intent)
+ }
+
+ binding.imgDetail2.setOnClickListener {
+ val intent = Intent(requireContext(), DetailActivity::class.java)
+ startActivity(intent)
+ }
+
+ // btnUpload 버튼 클릭 시 UploadActivity 실행
+ binding.btnUpload.setOnClickListener {
+ val intent = Intent(requireContext(), UploadActivity::class.java)
+ startActivity(intent)
+ }
+
+
+ binding.btnBattle.setOnClickListener {
+ val fragmentTransaction = parentFragmentManager.beginTransaction()
+ fragmentTransaction.replace(R.id.fragment_container, BattleFragment()) // fragment_battle.xml을 로드하는 Fragment
+ fragmentTransaction.addToBackStack(null) // 뒤로 가기 가능하도록 설정
+ fragmentTransaction.commit()
+ }
+ // btn_upload2 버튼 클릭 시 NewBattleActivity 실행
+ binding.btnUpload2.setOnClickListener {
+ val intent = Intent(requireContext(), NewBattleActivity::class.java)
+ startActivity(intent)
+ }
+ // gotochallenge 터치 시 ChallengeFragment 실행
+ binding.gotochallenge.setOnClickListener {
+ val fragmentTransaction = parentFragmentManager.beginTransaction()
+ fragmentTransaction.replace(R.id.fragment_container, ChallengeFragment()) // fragment_todaycloset.xml을 로드하는 Fragment
+ fragmentTransaction.addToBackStack(null) // 뒤로 가기 가능하도록 설정
+ fragmentTransaction.commit()
+ }
+
+ binding.imgBattle1.setOnClickListener {
+ val fragmentTransaction = parentFragmentManager.beginTransaction()
+ fragmentTransaction.replace(R.id.fragment_container, ChallengeFragment()) // fragment_todaycloset.xml을 로드하는 Fragment
+ fragmentTransaction.addToBackStack(null) // 뒤로 가기 가능하도록 설정
+ fragmentTransaction.commit()
+ }
+
+ binding.imgBattle2.setOnClickListener {
+ val fragmentTransaction = parentFragmentManager.beginTransaction()
+ fragmentTransaction.replace(R.id.fragment_container, ChallengeFragment()) // fragment_todaycloset.xml을 로드하는 Fragment
+ fragmentTransaction.addToBackStack(null) // 뒤로 가기 가능하도록 설정
+ fragmentTransaction.commit()
+ }
+
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/BattleAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattleAdapter.kt
new file mode 100644
index 0000000..4ed1834
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattleAdapter.kt
@@ -0,0 +1,48 @@
+package com.example.umc_closit.ui.community.battle
+
+import android.content.Context
+import android.content.Intent
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.post.UserRecentPostDTO
+import com.example.umc_closit.databinding.ItemBattle2Binding
+import com.example.umc_closit.ui.community.battle.NewBattleDetailActivity
+import com.example.umc_closit.databinding.ItemBattleBinding
+
+class BattleAdapter(
+ private val itemList: List,
+ private val context: Context
+) : RecyclerView.Adapter() {
+
+ inner class BattleViewHolder(val binding: ItemBattle2Binding) : RecyclerView.ViewHolder(binding.root)
+
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BattleViewHolder {
+ val binding = ItemBattle2Binding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return BattleViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: BattleViewHolder, position: Int) {
+ val post = itemList[position]
+ Glide.with(context)
+ .load(post.thumbnail)
+ .placeholder(R.drawable.img_gray_square) // 로딩 중일 때 기본 이미지
+ .error(R.drawable.img_gray_square) // 로딩 실패 시 기본 이미지
+ .centerCrop()
+ .into(holder.binding.imageView) // imageView는 item_battle.xml에 있는 이미지 뷰
+
+ holder.binding.imageView.setOnClickListener {
+ val intent = Intent(context, NewBattleDetailActivity::class.java).apply {
+ putExtra("thumbnail_url", post.thumbnail) // 클릭한 썸네일 URL 전달
+ putExtra("post_id", post.postId) // postId 전달
+ }
+ context.startActivity(intent)
+ }
+
+ }
+
+ override fun getItemCount(): Int = itemList.size
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/BattleFragment.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattleFragment.kt
new file mode 100644
index 0000000..92f9af8
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattleFragment.kt
@@ -0,0 +1,128 @@
+package com.example.umc_closit.ui.community.battle
+
+import android.content.Intent
+import android.graphics.Typeface
+import android.os.Bundle
+import android.util.Log
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.battle.BattleApiService
+import com.example.umc_closit.data.remote.battle.ChallengeBattlePreview
+import com.example.umc_closit.data.remote.battle.ChallengeBattleResponse
+import com.example.umc_closit.databinding.FragmentBattleBinding
+import com.example.umc_closit.ui.community.challenge.ChallengeFragment
+import com.example.umc_closit.utils.TokenUtils
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+
+class BattleFragment : Fragment() {
+
+ private var _binding: FragmentBattleBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ _binding = FragmentBattleBinding.inflate(inflater, container, false)
+
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val adapter = BattlePagerAdapter(this)
+ binding.viewPager.adapter = adapter
+
+ TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
+ val tabText = if (position == 0) "진행중" else "완료됨"
+ val textView = TextView(requireContext()).apply {
+ text = tabText
+ textSize = 12f
+ includeFontPadding = false
+ setTextColor(ContextCompat.getColor(requireContext(), R.color.light_gray))
+ typeface = Typeface.DEFAULT
+ gravity = Gravity.BOTTOM
+ setPadding(0, 0, 0, 0) // ✅ 패딩 제거
+ minHeight = 0 // ✅ 최소 높이 제거
+ setLineSpacing(0f, 1f) // ✅ 줄 간격 줄이기
+ }
+ tab.customView = textView
+ }.attach()
+
+ binding.clChallengeWrapper.setOnClickListener{
+ val fragmentTransaction = parentFragmentManager.beginTransaction()
+ fragmentTransaction.replace(R.id.fragment_container, ChallengeFragment())
+ fragmentTransaction.addToBackStack(null)
+ fragmentTransaction.commit()
+ }
+
+ binding.clChallenge.setOnClickListener {
+ val intent = Intent(requireContext(), NewBattleActivity::class.java)
+ startActivity(intent)
+ }
+
+ binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: TabLayout.Tab) {
+ val textView = tab.customView as? TextView
+ textView?.setTextColor(ContextCompat.getColor(requireContext(), R.color.black))
+ textView?.setTypeface(null, Typeface.BOLD)
+ }
+
+ override fun onTabUnselected(tab: TabLayout.Tab) {
+ val textView = tab.customView as? TextView
+ textView?.setTextColor(ContextCompat.getColor(requireContext(), R.color.light_gray))
+ textView?.setTypeface(null, Typeface.NORMAL)
+ }
+
+ override fun onTabReselected(tab: TabLayout.Tab) {}
+ })
+
+ // 처음 시작 시 첫 번째 탭에 스타일 적용
+ val firstTab = binding.tabLayout.getTabAt(0)
+ val firstTextView = firstTab?.customView as? TextView
+ firstTextView?.setTextColor(ContextCompat.getColor(requireContext(), R.color.black))
+ firstTextView?.setTypeface(null, Typeface.BOLD)
+
+ // 챌린지 데이터 불러오기
+ fetchChallengeBattles()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun fetchChallengeBattles() {
+ val apiService = RetrofitClient.createService(BattleApiService::class.java)
+
+ TokenUtils.handleTokenRefresh(
+ call = apiService.getChallengeBattles(page = 0),
+ onSuccess = { response ->
+ val result = response as ChallengeBattleResponse
+ if (result.isSuccess && result.result != null) {
+ val challengeList = result.result.challengeBattlePreviewList
+ Log.d("ChallengeList", "받은 리스트 크기: ${challengeList.size}")
+ challengeList.forEach {
+ Log.d("ChallengeList", "title=${it.title}, id=${it.battleId}")
+ }
+ val adapter = ChallengePreviewAdapter(challengeList, requireContext())
+ binding.rvChallenge.layoutManager =
+ LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
+ binding.rvChallenge.adapter = adapter
+ } else {
+ Toast.makeText(requireContext(), "불러오기 실패: ${result.message}", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onFailure = { error ->
+ Toast.makeText(requireContext(), "네트워크 오류: ${error.message}", Toast.LENGTH_SHORT).show()
+ },
+ context = requireContext()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/BattlePageAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattlePageAdapter.kt
new file mode 100644
index 0000000..7800f0a
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattlePageAdapter.kt
@@ -0,0 +1,190 @@
+package com.example.umc_closit.Community
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.ProgressBar
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.entities.BattleItem
+import com.example.umc_closit.databinding.ItemBattleMainBinding
+import com.example.umc_closit.data.BattleViewModel
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.battle.LikeResponse
+import com.example.umc_closit.data.remote.battle.VoteResponse
+import com.example.umc_closit.ui.timeline.comment.CommentBottomSheetFragment
+import com.example.umc_closit.ui.battle.comment.BattleCommentBottomSheetFragment
+import com.example.umc_closit.utils.TokenUtils
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class BattlePageAdapter(
+ private val context: Context,
+ private var battleItems: MutableList
+) : RecyclerView.Adapter() {
+
+ // ViewModelProvider 수정: AndroidX Lifecycle 방식
+ private val battleViewModel by lazy {
+ ViewModelProvider(
+ context as AppCompatActivity,
+ ViewModelProvider.AndroidViewModelFactory(context.application)
+ )[BattleViewModel::class.java]
+ }
+
+ private val apiService = RetrofitClient.battleApiService
+
+ class ViewHolder(val binding: ItemBattleMainBinding) : RecyclerView.ViewHolder(binding.root)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val binding = ItemBattleMainBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = battleItems[position]
+
+ with(holder.binding) {
+
+ Glide.with(context)
+ .load(item.leftPostImageUrl) // leftPostImageUrl 필드 추가 필요
+ .placeholder(R.drawable.img_gray_square)
+ .error(R.drawable.img_gray_square)
+ .centerCrop()
+ .into(leftItem)
+
+ Glide.with(context)
+ .load(item.rightPostImageUrl) // rightPostImageUrl 필드 추가 필요
+ .placeholder(R.drawable.img_gray_square)
+ .error(R.drawable.img_gray_square)
+ .centerCrop()
+ .into(rightItem)
+
+ // 좌측 battleID 표시
+ tvLeftVote.text = "Left: ${item.battleId}"
+
+ // 우측 battleID 표시
+ tvRightVote.text = "Right: ${item.battleId}"
+
+ // 댓글 클릭 시 CommentBottomSheetFragment 호출
+ ivComment.setOnClickListener {
+ BattleCommentBottomSheetFragment.newInstance(item.battleId).show(
+ (context as AppCompatActivity).supportFragmentManager,
+ "comment"
+ )
+ }
+
+ // 좋아요 상태 반영
+ val isLiked = battleViewModel.getLikeStatus(item.id) ?: false
+ ivLike.setImageResource(if (isLiked) R.drawable.ic_like_on else R.drawable.ic_like_off)
+
+ // 좋아요 버튼 클릭 이벤트
+ ivLike.setOnClickListener {
+ val newLikeState = !isLiked
+ battleViewModel.updateLikeStatus(item.id, newLikeState)
+ ivLike.setImageResource(if (newLikeState) R.drawable.ic_like_on else R.drawable.ic_like_off)
+
+ if (newLikeState) {
+ apiService.addBattleLike(item.battleId).enqueue(createLikeCallback("좋아요!"))
+ } else {
+ apiService.removeBattleLike(item.battleLikeId)
+ .enqueue(createLikeCallback("좋아요 취소!"))
+
+ }
+ }
+
+
+
+ // 투표 버튼 클릭 이벤트
+ tvLeftVote.setOnClickListener { sendVote(item.battleId, item.leftPostId, voteProgressBar) }
+ tvRightVote.setOnClickListener { sendVote(item.battleId, item.rightPostId, voteProgressBar) }
+ }
+ }
+
+ /**
+ * 투표 요청 처리 (TokenUtils 적용)
+ */
+ private fun sendVote(battleId: Long, postId: Int, progressBar: ProgressBar) {
+ val requestBody = mapOf("postId" to postId)
+
+ TokenUtils.handleTokenRefresh(
+ call = apiService.voteBattle(battleId, requestBody), // 변경된 부분: battleId를 PathVariable로 전달
+ onSuccess = { voteResponse: VoteResponse ->
+ if (voteResponse.isSuccess) {
+ val firstVotingRate = voteResponse.result?.firstVotingRate?.toDouble() ?: 0.0
+ val secondVotingRate = voteResponse.result?.secondVotingRate?.toDouble() ?: 0.0
+ val totalVotes = firstVotingRate + secondVotingRate
+
+ val progress = if (totalVotes > 0) {
+ ((firstVotingRate * 100.0) / totalVotes).toInt() // 결과를 Int로 변환
+ } else {
+ 50 // 기본값
+ }
+
+
+ animateProgress(progressBar, progress)
+
+ Toast.makeText(
+ context,
+ "투표 성공! ${firstVotingRate}% vs ${secondVotingRate}%",
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ context,
+ "투표 실패: ${voteResponse.message}",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ },
+ onFailure = { throwable ->
+ Log.e("Vote", "API 호출 실패", throwable)
+ Toast.makeText(context, "네트워크 오류: ${throwable.message}", Toast.LENGTH_SHORT).show()
+ },
+ context = context
+ )
+ }
+
+
+ /**
+ * ProgressBar 애니메이션
+ */
+ private fun animateProgress(progressBar: ProgressBar, target: Int) {
+ ValueAnimator.ofInt(progressBar.progress, target).apply {
+ duration = 800L
+ addUpdateListener { progressBar.progress = it.animatedValue as Int }
+ start()
+ }
+ }
+
+ /**
+ * 좋아요 요청 처리
+ */
+ private fun createLikeCallback(message: String): Callback {
+ return object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ if (response.isSuccessful) {
+ val body = response.body()
+ if (body != null && body.isSuccess) {
+ Toast.makeText(context, "$message 성공: ${body.result}", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(context, "$message 실패: ${body?.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ Toast.makeText(context, "$message 실패: ${t.localizedMessage}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+
+ override fun getItemCount(): Int = battleItems.size
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/BattlePagerAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattlePagerAdapter.kt
new file mode 100644
index 0000000..4e0cb22
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/BattlePagerAdapter.kt
@@ -0,0 +1,12 @@
+package com.example.umc_closit.ui.community.battle
+
+import androidx.fragment.app.Fragment
+import androidx.viewpager2.adapter.FragmentStateAdapter
+
+class BattlePagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
+ override fun getItemCount(): Int = 2
+
+ override fun createFragment(position: Int): Fragment {
+ return if (position == 0) OngoingBattleFragment() else CompletedBattleFragment()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/ChallengePreviewAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/ChallengePreviewAdapter.kt
new file mode 100644
index 0000000..b6800e2
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/ChallengePreviewAdapter.kt
@@ -0,0 +1,75 @@
+package com.example.umc_closit.ui.community.battle
+
+import android.content.Context
+import android.content.Intent
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.battle.ChallengeBattlePreview
+import com.example.umc_closit.databinding.ItemChallengePreviewBinding
+import com.example.umc_closit.ui.community.challenge.NewChallengeActivity
+import com.example.umc_closit.utils.FileUtils
+
+class ChallengePreviewAdapter(
+ private val challengeList: List,
+ private val context: Context
+) : RecyclerView.Adapter() {
+
+ inner class ViewHolder(private val binding: ItemChallengePreviewBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(challenge: ChallengeBattlePreview) {
+ // 이미지 로딩
+ Glide.with(binding.root.context)
+ .load(challenge.firstPostFrontImage)
+ .placeholder(R.drawable.image_background)
+ .into(binding.ivImageBig)
+
+ Glide.with(binding.root.context)
+ .load(challenge.firstPostBackImage)
+ .placeholder(R.drawable.image_background)
+ .into(binding.ivImageSmall)
+
+ var isFrontImageBig = true
+ val fakeTagContainer = ConstraintLayout(binding.root.context)
+
+ // 이미지 스왑 처리
+ binding.ivImageSmall.setOnClickListener {
+ FileUtils.swapImagesWithTagEffect(
+ bigImageView = binding.ivImageBig,
+ smallImageView = binding.ivImageSmall,
+ tagContainer = fakeTagContainer
+ ) {
+ isFrontImageBig = !isFrontImageBig
+ }
+ }
+
+ // 클릭 시 NewChallengeActivity로 이동
+ binding.clChallengeWrapper.setOnClickListener {
+ val intent = Intent(binding.root.context, NewChallengeActivity::class.java)
+ intent.putExtra("challenge_data", challenge)
+ binding.root.context.startActivity(intent)
+ Toast.makeText(binding.root.context, "도전하기!", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val binding = ItemChallengePreviewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val layoutParams = holder.itemView.layoutParams
+ val screenWidth = holder.itemView.context.resources.displayMetrics.widthPixels
+ layoutParams.width = screenWidth / 9 * 2
+ holder.itemView.layoutParams = layoutParams
+
+ holder.bind(challengeList[position])
+ }
+ override fun getItemCount(): Int = challengeList.size
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/CompletedBattleFragment.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/CompletedBattleFragment.kt
new file mode 100644
index 0000000..365b115
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/CompletedBattleFragment.kt
@@ -0,0 +1,125 @@
+package com.example.umc_closit.ui.community.battle
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.example.umc_closit.Community.BattlePageAdapter
+import com.example.umc_closit.R
+import com.example.umc_closit.data.entities.BattleItem
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.battle.BattleListResponse
+import com.example.umc_closit.databinding.FragmentCompletedBattleBinding
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class CompletedBattleFragment : Fragment() {
+
+ private var _binding: FragmentCompletedBattleBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var battleAdapter: BattlePageAdapter
+ private val battleList = mutableListOf()
+
+ private var isLoading = false
+ private var currentPage = 0
+ private var isLastPage = false
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentCompletedBattleBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ battleAdapter = BattlePageAdapter(requireContext(), battleList)
+ binding.rvCompleted.layoutManager = LinearLayoutManager(requireContext())
+ binding.rvCompleted.adapter = battleAdapter
+
+ fetchBattleList(currentPage)
+
+ // 스크롤 리스너
+ binding.rvCompleted.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+ val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
+ val totalItemCount = layoutManager.itemCount
+
+ if (!isLoading && !isLastPage && lastVisibleItem + 1 >= totalItemCount) {
+ currentPage++
+ fetchBattleList(currentPage)
+ }
+ }
+ })
+ }
+
+ private fun fetchBattleList(page: Int) {
+ isLoading = true
+ val call = RetrofitClient.battleApiService.getBattleList(page, "LATEST", "COMPLETED")
+
+ call.enqueue(object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response
+ ) {
+ isLoading = false
+
+ val battleResponse = response.body()
+
+ if (response.isSuccessful && battleResponse != null && battleResponse.isSuccess) {
+ val battles = battleResponse.result?.battlePreviewList
+ if (page == 0) battleList.clear()
+
+ if (!battles.isNullOrEmpty()) {
+ binding.tvNobattle.visibility = View.GONE
+ battleList.addAll(battles.map { preview ->
+ BattleItem(
+ id = preview.battleId.toInt(),
+ battleId = preview.battleId,
+ userProfileUrl = preview.firstProfileImage,
+ userName = preview.firstClositId,
+ battleLikeId = 0,
+ leftPostId = preview.firstPostId,
+ rightPostId = preview.secondPostId,
+ leftPostImageUrl = preview.firstPostFrontImage,
+ rightPostImageUrl = preview.secondPostFrontImage
+ )
+ })
+ battleAdapter.notifyDataSetChanged()
+
+ // 더 이상 불러올 게 없는 경우
+ if (battles.size < 10) isLastPage = true // 한 페이지에 10개라 가정
+ } else {
+ if (page == 0) {
+ binding.tvNobattle.visibility = View.VISIBLE
+ }
+ isLastPage = true
+ }
+ } else {
+ Toast.makeText(requireContext(), "API 실패: ${battleResponse?.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ isLoading = false
+ Log.e("API_ERROR", "네트워크 오류: ${t.localizedMessage}")
+ Toast.makeText(requireContext(), "네트워크 오류 발생", Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/NewBattleActivity.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/NewBattleActivity.kt
new file mode 100644
index 0000000..760621d
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/NewBattleActivity.kt
@@ -0,0 +1,83 @@
+package com.example.umc_closit.ui.community.battle
+
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import com.bumptech.glide.Glide
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.post.RecentPostResponse
+import com.example.umc_closit.data.remote.post.UserRecentPostDTO
+import com.example.umc_closit.databinding.ActivityNewbattleBinding
+import com.example.umc_closit.utils.TokenUtils
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import retrofit2.Response
+
+
+class NewBattleActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityNewbattleBinding
+ private lateinit var adapter: BattleAdapter
+ private val itemList = mutableListOf() // thumbnail URL 리스트
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityNewbattleBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ setupRecyclerView()
+
+ binding.ivBack.setOnClickListener {
+ onBackPressed() // 뒤로 가기
+ }
+ val myClositId = TokenUtils.getClositId(this) ?: ""
+ fetchRecentPosts(myClositId)
+ }
+
+ private fun setupRecyclerView() {
+ adapter = BattleAdapter(itemList, this)
+ binding.challengeRecyclerView.layoutManager = GridLayoutManager(this, 3) // 한 줄에 3개
+ binding.challengeRecyclerView.adapter = adapter
+ }
+
+ private fun fetchRecentPosts(clositId: String) {
+ // Call 객체 생성
+ val call = RetrofitClient.postService.getRecentPosts(clositId, 0)
+
+ // 비동기 호출
+ call.enqueue(object : retrofit2.Callback {
+ override fun onResponse(
+ call: retrofit2.Call,
+ response: retrofit2.Response
+ ) {
+ if (response.isSuccessful) {
+ val recentPostResponse = response.body()
+ if (recentPostResponse != null && recentPostResponse.isSuccess) {
+ val posts = recentPostResponse.result.userRecentPostDTOList
+ if (posts.isNotEmpty()) {
+ itemList.clear()
+ itemList.addAll(posts)
+ adapter.notifyDataSetChanged()
+ } else {
+ Toast.makeText(this@NewBattleActivity, "데이터가 없습니다.", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(this@NewBattleActivity, "API 실패: ${recentPostResponse?.message}", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Log.e("API_ERROR", "응답 실패: ${response.code()} - ${response.message()}")
+ Toast.makeText(this@NewBattleActivity, "불러오기 실패", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onFailure(call: retrofit2.Call, t: Throwable) {
+ Log.e("API_ERROR", "네트워크 오류: ${t.message}")
+ Toast.makeText(this@NewBattleActivity, "네트워크 오류 발생", Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/NewBattleDetailActivity.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/NewBattleDetailActivity.kt
new file mode 100644
index 0000000..56c1fd9
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/NewBattleDetailActivity.kt
@@ -0,0 +1,116 @@
+// NewBattleDetailActivity.kt
+package com.example.umc_closit.ui.community.battle
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.widget.Button
+import android.widget.EditText
+import android.widget.ImageView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.BattlePostRequest
+import com.example.umc_closit.data.BattlePostResponse
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.ui.timeline.TimelineActivity
+import com.example.umc_closit.utils.TokenUtils
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class NewBattleDetailActivity : AppCompatActivity() {
+
+ private lateinit var titleEditText: EditText
+ private lateinit var uploadButton: Button
+ private lateinit var backButton: ImageView
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_new_battle_detail)
+
+ // UI 요소 연결
+ titleEditText = findViewById(R.id.et_battle_title)
+ uploadButton = findViewById(R.id.btn_upload)
+ backButton = findViewById(R.id.iv_back)
+ val ivImageBig = findViewById(R.id.iv_image_big)
+
+ // ✅ 전달받은 썸네일 URL 가져오기
+ val thumbnailUrl = intent.getStringExtra("thumbnail_url")
+ val postId = intent.getIntExtra("post_id", -1)
+
+ Log.d("Thumbnail at first", "Image URL: ${thumbnailUrl}")
+
+
+ if (!thumbnailUrl.isNullOrEmpty()) {
+ Glide.with(this)
+ .load(thumbnailUrl)
+ .placeholder(R.drawable.img_gray_square)
+ .error(R.drawable.img_gray_square)
+ .into(ivImageBig)
+ } else {
+ Toast.makeText(this, "이미지 정보를 불러올 수 없습니다.", Toast.LENGTH_SHORT).show()
+ }
+
+ // "뒤로 가기" 버튼 클릭 시
+ backButton.setOnClickListener {
+ finish()
+ }
+
+ // "업로드" 버튼 클릭 시
+ uploadButton.setOnClickListener {
+ val title = titleEditText.text.toString().trim()
+ if (title.isNotEmpty()) {
+ uploadBattlePost(postId, title)
+ val intent = Intent(this, TimelineActivity::class.java)
+ startActivity(intent)
+ } else {
+ Toast.makeText(this, "제목을 입력해주세요.", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ /**
+ * 배틀 업로드 API 호출
+ */
+ private fun uploadBattlePost(postId: Int, title: String) {
+ val request = BattlePostRequest(
+ postId = postId, // ✅ 전달받은 postId 사용
+ title = title,
+ description = ""
+ )
+
+ TokenUtils.handleTokenRefresh(
+ call = RetrofitClient.battleApiService.uploadBattle(request),
+ onSuccess = { response ->
+ if (response.isSuccess) {
+ Toast.makeText(
+ this@NewBattleDetailActivity,
+ "업로드 성공! 배틀 ID: ${response.result?.battleId}",
+ Toast.LENGTH_LONG
+ ).show()
+ Log.d("Thumbnail", "Image URL: ${response.result?.thumbnail}")
+
+ // 성공 후 타임라인으로 이동
+ val intent = Intent(this, TimelineActivity::class.java)
+ startActivity(intent)
+ finish()
+ } else {
+ Toast.makeText(
+ this@NewBattleDetailActivity,
+ "업로드 실패: ${response.message ?: "알 수 없는 오류"}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ },
+ onFailure = { throwable ->
+ Log.e("BattleUpload", "API 호출 실패", throwable)
+ Toast.makeText(this@NewBattleDetailActivity, "네트워크 오류", Toast.LENGTH_SHORT).show()
+ },
+ context = this@NewBattleDetailActivity
+ )
+ }
+
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/OngoingBattleFragment.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/OngoingBattleFragment.kt
new file mode 100644
index 0000000..6028c44
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/OngoingBattleFragment.kt
@@ -0,0 +1,120 @@
+package com.example.umc_closit.ui.community.battle
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.example.umc_closit.Community.BattlePageAdapter
+import com.example.umc_closit.R
+import com.example.umc_closit.data.entities.BattleItem
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.battle.BattleListResponse
+import com.example.umc_closit.databinding.FragmentCompletedBattleBinding
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class OngoingBattleFragment : Fragment() {
+
+ private var _binding: FragmentCompletedBattleBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var battleAdapter: BattlePageAdapter
+ private val battleList = mutableListOf()
+
+ private var isLoading = false
+ private var currentPage = 0
+ private var isLastPage = false
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentCompletedBattleBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ battleAdapter = BattlePageAdapter(requireContext(), battleList)
+ binding.rvCompleted.layoutManager = LinearLayoutManager(requireContext())
+ binding.rvCompleted.adapter = battleAdapter
+
+ fetchBattleList(currentPage)
+
+ binding.rvCompleted.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+ val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
+ val totalItemCount = layoutManager.itemCount
+
+ if (!isLoading && !isLastPage && lastVisibleItem + 1 >= totalItemCount) {
+ currentPage++
+ fetchBattleList(currentPage)
+ }
+ }
+ })
+ }
+
+ private fun fetchBattleList(page: Int) {
+ isLoading = true
+ val call = RetrofitClient.battleApiService.getBattleList(page, "LATEST", "ACTIVE")
+ call.enqueue(object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response
+ ) {
+ isLoading = false
+ val battleResponse = response.body()
+
+ if (response.isSuccessful && battleResponse != null && battleResponse.isSuccess) {
+ val battles = battleResponse.result?.battlePreviewList
+ if (page == 0) battleList.clear()
+
+ if (!battles.isNullOrEmpty()) {
+ binding.tvNobattle.visibility = View.GONE
+ battleList.addAll(battles.map { preview ->
+ BattleItem(
+ id = preview.battleId.toInt(),
+ battleId = preview.battleId,
+ userProfileUrl = preview.firstProfileImage,
+ userName = preview.firstClositId,
+ battleLikeId = 0,
+ leftPostId = preview.firstPostId,
+ rightPostId = preview.secondPostId,
+ leftPostImageUrl = preview.firstPostFrontImage,
+ rightPostImageUrl = preview.secondPostFrontImage
+ )
+ })
+ battleAdapter.notifyDataSetChanged()
+
+ if (battles.size < 10) isLastPage = true // 페이지 사이즈 10 기준
+ } else {
+ if (page == 0) binding.tvNobattle.visibility = View.VISIBLE
+ isLastPage = true
+ }
+ } else {
+ Toast.makeText(requireContext(), "API 실패", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ isLoading = false
+ Log.e("API_ERROR", "네트워크 오류: ${t.localizedMessage}")
+ Toast.makeText(requireContext(), "네트워크 오류 발생", Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentAdapter.kt
new file mode 100644
index 0000000..b6f47c5
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentAdapter.kt
@@ -0,0 +1,165 @@
+package com.example.umc_closit.ui.battle.comment
+
+import BattleComment
+import android.graphics.Typeface
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.style.StyleSpan
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.text.bold
+import androidx.core.text.buildSpannedString
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.BaseResponse
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.profile.ProfileUserResponse
+import com.example.umc_closit.databinding.ItemCommentBinding
+import com.example.umc_closit.utils.DateUtils
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class BattleCommentAdapter(
+ private val commentList: MutableList,
+ private val onDeleteComment: (Int) -> Unit,
+ private val onReplyClick: (BattleComment) -> Unit
+) : RecyclerView.Adapter() {
+
+ inner class BattleCommentViewHolder(val binding: ItemCommentBinding) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(comment: BattleComment) {
+ with(binding) {
+
+ tvLikeCount.setOnClickListener {
+ onReplyClick(comment)
+ }
+
+ val content = comment.content
+ val spannable = SpannableStringBuilder(content)
+ val mentionRegex = Regex("@(\\w+)")
+ val matches = mentionRegex.findAll(content)
+
+ matches.forEach { match ->
+ val id = match.groupValues[1] // '@' 제외한 아이디
+
+ RetrofitClient.authService.checkIdUnique(id).enqueue(object : Callback> {
+ override fun onResponse(
+ call: Call>,
+ response: Response>
+ ) {
+ val exists = response.body()?.result == false // 이미 있는 아이디
+ if (exists) {
+ spannable.setSpan(
+ StyleSpan(Typeface.BOLD),
+ match.range.first,
+ match.range.last + 1,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ binding.tvCommentText.text = spannable
+ }
+ }
+
+ override fun onFailure(call: Call>, t: Throwable) {
+ // 실패해도 무시
+ Log.e("CommentBind", "ID 확인 실패: $id")
+ }
+ })
+ }
+
+// 초기 텍스트는 일단 전체 그대로 넣어두기
+ binding.tvCommentText.text = spannable
+ tvCreateTime.text = DateUtils.getTimeAgo(comment.createdAt)
+
+ // 대댓글 여부에 따라 여백 - 가로 너비의 10%
+ val isReply = comment.parentBattleCommentId != 0L
+ val screenWidth = clProfile.resources.displayMetrics.widthPixels
+ val paddingStart = if (isReply) (screenWidth * 0.1).toInt() else 0
+
+ clProfile.setPaddingRelative(
+ paddingStart,
+ clProfile.paddingTop,
+ clProfile.paddingEnd,
+ clProfile.paddingBottom
+ )
+
+ tvUserName.text = comment.clositId
+
+ Glide.with(root.context)
+ .load(comment.thumbnail) // 🔥 실제 썸네일 적용
+ .placeholder(R.drawable.img_profile_default) // 로딩 중 기본 이미지
+ .error(R.drawable.img_profile_default) // 에러 시 기본 이미지
+ .circleCrop()
+ .into(ivUserProfile)
+ }
+ }
+
+ fun updateTime(comment: BattleComment) {
+ binding.tvCreateTime.text = DateUtils.getTimeAgo(comment.createdAt)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BattleCommentViewHolder {
+ val binding = ItemCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return BattleCommentViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: BattleCommentViewHolder, position: Int) {
+ val comment = commentList[position]
+ holder.bind(comment)
+ fetchUserInfo(comment.clositId, holder)
+ }
+
+ override fun onBindViewHolder(holder: BattleCommentViewHolder, position: Int, payloads: MutableList) {
+ val comment = commentList[position]
+ if (payloads.isNotEmpty() && payloads[0] == "timeUpdate") {
+ holder.updateTime(comment)
+ } else {
+ holder.bind(comment)
+ }
+ }
+
+ override fun getItemCount(): Int = commentList.size
+
+ fun updateTimeForAllItems() {
+ for (i in commentList.indices) {
+ notifyItemChanged(i, "timeUpdate")
+ }
+ }
+
+ fun removeItem(position: Int) {
+ val comment = commentList[position]
+ onDeleteComment(comment.battleCommentId)
+ commentList.removeAt(position)
+ notifyItemRemoved(position)
+ }
+
+ fun isUserComment(position: Int, myClositId: String): Boolean {
+ return commentList[position].clositId == myClositId
+ }
+
+ private fun fetchUserInfo(clositId: String, holder: BattleCommentViewHolder) {
+ RetrofitClient.profileService.getUserProfile(clositId)
+ .enqueue(object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ if (response.isSuccessful) {
+ val userInfo = response.body()?.result
+ if (userInfo != null) {
+ holder.binding.tvUserName.text = userInfo.name ?: clositId
+ Glide.with(holder.binding.root.context)
+ .load(userInfo.profileImage ?: R.drawable.img_profile_default)
+ .circleCrop()
+ .into(holder.binding.ivUserProfile)
+ }
+ } else {
+ Log.e("BattleCommentAdapter", "Failed to fetch user info: ${response.errorBody()?.string()}")
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ Log.e("BattleCommentAdapter", "Error fetching user info: ${t.message}")
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentBottomSheetFragment.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentBottomSheetFragment.kt
new file mode 100644
index 0000000..c743e9f
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentBottomSheetFragment.kt
@@ -0,0 +1,269 @@
+package com.example.umc_closit.ui.battle.comment
+
+import BattleComment
+import CommentRequest
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.databinding.FragmentCommentBottomSheetBinding
+import com.example.umc_closit.utils.TokenUtils
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+
+class BattleCommentBottomSheetFragment : BottomSheetDialogFragment() {
+
+ private lateinit var binding: FragmentCommentBottomSheetBinding
+ private lateinit var commentAdapter: BattleCommentAdapter
+ private val comments = mutableListOf()
+
+ private var battleId: Long = -1
+ private var page: Int = 0
+ private var hasNext = true
+
+ private var replyingToCommentId: Long? = null
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val timeUpdateRunnable = object : Runnable {
+ override fun run() {
+ commentAdapter.updateTimeForAllItems()
+ handler.postDelayed(this, 10000)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ battleId = arguments?.getLong("battleId") ?: -1
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentCommentBottomSheetBinding.inflate(inflater, container, false)
+
+ commentAdapter = BattleCommentAdapter(comments, ::deleteComment, ::onReplyClick)
+ binding.commentsRecyclerView.layoutManager = LinearLayoutManager(context)
+ binding.commentsRecyclerView.adapter = commentAdapter
+
+ val myClositId = TokenUtils.getClositId(requireContext()) ?: ""
+ val itemTouchHelper = ItemTouchHelper(BattleCommentSwipeCallback(commentAdapter, myClositId))
+ itemTouchHelper.attachToRecyclerView(binding.commentsRecyclerView)
+
+ loadComments()
+
+ binding.ivSubmit.setOnClickListener { postComment() }
+
+ binding.commentsRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+ if (layoutManager.findLastVisibleItemPosition() == layoutManager.itemCount - 1 && hasNext) {
+ loadComments()
+ }
+ }
+ })
+
+ return binding.root
+ }
+
+ private var isLoading = false
+
+ private fun onReplyClick(comment: BattleComment) {
+ replyingToCommentId = comment.battleCommentId.toLong()
+
+ // "@닉네임 " 자동입력
+ binding.commentEditText.setText("@${comment.clositId} ")
+ binding.commentEditText.setSelection(binding.commentEditText.text.length)
+
+ // 키보드 포커스
+ binding.commentEditText.requestFocus()
+ val imm = requireContext().getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
+ imm.showSoftInput(binding.commentEditText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
+ }
+
+ private fun loadComments() {
+ if (!hasNext || isLoading) {
+ Log.d("BATTLE_COMMENT_LOAD", "로딩 중단 - hasNext: $hasNext, isLoading: $isLoading")
+ return
+ }
+ isLoading = true
+ Log.d("BATTLE_COMMENT_LOAD", "댓글 로딩 시작 - page: $page")
+
+ val apiCall = { RetrofitClient.battleApiService.getBattleComments(battleId, page) }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ Log.d("BATTLE_COMMENT_LOAD", "응답 성공 - isSuccess: ${response.isSuccess}, message: ${response.message}")
+
+ val result = response.result
+ if (response.isSuccess && result != null) {
+ val newComments = result.battleCommentPreviewList ?: emptyList()
+ val sorted = sortCommentsWithReplies(newComments)
+
+ comments.clear()
+ comments.addAll(sorted)
+ commentAdapter.notifyDataSetChanged()
+
+ hasNext = result.hasNext
+ page++
+
+ updateNoCommentTextViewVisibility()
+ } else {
+ Log.e("BATTLE_COMMENT_LOAD", "댓글 불러오기 실패 - message: ${response.message}, result: $result")
+ }
+ isLoading = false
+ },
+ onFailure = { t ->
+ Log.e("BATTLE_COMMENT_LOAD", "댓글 불러오기 네트워크 오류 - ${t.message}")
+ isLoading = false
+ },
+ context = requireContext()
+ )
+ }
+
+ private fun postComment() {
+ val content = binding.commentEditText.text.toString().trim()
+ if (content.isEmpty()) {
+ Log.d("BATTLE_COMMENT", "입력된 내용이 비어 있음, 요청 중단")
+ return
+ }
+
+ if (battleId <= 0) {
+ Log.e("BATTLE_COMMENT", "유효하지 않은 battleId: $battleId")
+ Toast.makeText(context, "유효하지 않은 배틀 ID입니다.", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val parentId = replyingToCommentId
+ Log.d("BATTLE_COMMENT", "댓글 작성 요청 준비 - content: \"$content\", battleId: $battleId, parentId: ${parentId ?: "없음"}")
+
+ val apiCall = {
+ RetrofitClient.battleApiService.postBattleComment(
+ battleId,
+ CommentRequest(content, parentId)
+ )
+ }
+
+ binding.commentEditText.text.clear()
+ replyingToCommentId = null
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ Log.d("BATTLE_COMMENT", "응답 수신 - isSuccess: ${response.isSuccess}, message: ${response.message}")
+
+ if (response.isSuccess) {
+ val result = response.result
+ Log.d("BATTLE_COMMENT", "작성된 댓글 ID: ${result.battleCommentId}, 작성자: ${result.clositId}, 작성시각: ${result.createdAt}")
+
+ val newComment = BattleComment(
+ battleCommentId = result.battleCommentId,
+ clositId = result.clositId,
+ content = content,
+ parentBattleCommentId = parentId ?: 0,
+ thumbnail = "string", // 실제 썸네일로 대체 필요
+ createdAt = result.createdAt
+ )
+
+ val insertIndex = if (parentId != null && parentId != 0L) {
+ comments.indexOfLast { it.battleCommentId.toLong() == parentId } + 1
+ } else {
+ comments.indexOfLast { it.parentBattleCommentId == 0L } + 1
+ }
+ comments.add(newComment)
+ val sorted = sortCommentsWithReplies(comments)
+ comments.clear()
+ comments.addAll(sorted)
+ commentAdapter.notifyDataSetChanged()
+
+ updateNoCommentTextViewVisibility()
+ } else {
+ Log.e("BATTLE_COMMENT", "API 실패 - ${response.message}")
+ Toast.makeText(context, "댓글 작성 실패: ${response}", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onFailure = { t ->
+ Log.e("BATTLE_COMMENT", "네트워크 오류 - ${t}")
+ Toast.makeText(context, "댓글 작성 실패: ${t}", Toast.LENGTH_SHORT).show()
+ },
+ context = requireContext()
+ )
+ }
+
+ private fun deleteComment(commentId: Int) {
+ val apiCall = { RetrofitClient.battleApiService.deleteBattleComment(battleId, commentId) }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ if (response.isSuccess) {
+ val position = comments.indexOfFirst { it.battleCommentId == commentId }
+ if (position != -1) {
+ comments.removeAt(position)
+ commentAdapter.notifyItemRemoved(position)
+ updateNoCommentTextViewVisibility()
+ }
+ }
+ },
+ onFailure = { t ->
+ Toast.makeText(context, "댓글 삭제 실패: ${t.message}", Toast.LENGTH_SHORT).show()
+ },
+ context = requireContext()
+ )
+ }
+
+ private fun updateNoCommentTextViewVisibility() {
+ if (comments.isEmpty()) {
+ binding.tvNoComment.visibility = View.VISIBLE
+ binding.commentsRecyclerView.visibility = View.GONE
+ } else {
+ binding.tvNoComment.visibility = View.GONE
+ binding.commentsRecyclerView.visibility = View.VISIBLE
+ }
+ }
+
+ private fun sortCommentsWithReplies(comments: List): List {
+ val result = mutableListOf()
+ val commentMap = comments.groupBy { it.parentBattleCommentId }
+
+ fun addReplies(parentId: Long) {
+ commentMap[parentId]?.forEach { comment ->
+ result.add(comment)
+ addReplies(comment.battleCommentId.toLong())
+ }
+ }
+
+ addReplies(0L)
+ return result
+ }
+
+
+ companion object {
+ fun newInstance(battleId: Long): BattleCommentBottomSheetFragment {
+ val fragment = BattleCommentBottomSheetFragment()
+ val args = Bundle()
+ args.putLong("battleId", battleId)
+ fragment.arguments = args
+ return fragment
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ handler.postDelayed(timeUpdateRunnable, 10000)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ handler.removeCallbacks(timeUpdateRunnable)
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentSwipeCallback.kt b/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentSwipeCallback.kt
new file mode 100644
index 0000000..4513a07
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/battle/comment/BattleCommentSwipeCallback.kt
@@ -0,0 +1,78 @@
+package com.example.umc_closit.ui.battle.comment
+
+import android.graphics.Canvas
+import android.graphics.drawable.ColorDrawable
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+import com.example.umc_closit.R
+
+class BattleCommentSwipeCallback(
+ private val adapter: BattleCommentAdapter,
+ private val myClositId: String // 내 ID 비교용
+) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
+
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean = false
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ val position = viewHolder.bindingAdapterPosition
+ adapter.removeItem(position)
+ }
+
+ override fun getSwipeDirs(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder
+ ): Int {
+ // 내 댓글만 스와이프 허용
+ val position = viewHolder.bindingAdapterPosition
+ return if (adapter.isUserComment(position, myClositId)) {
+ super.getSwipeDirs(recyclerView, viewHolder)
+ } else {
+ 0 // 스와이프 막기
+ }
+ }
+
+ override fun onChildDraw(
+ c: Canvas,
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ dX: Float,
+ dY: Float,
+ actionState: Int,
+ isCurrentlyActive: Boolean
+ ) {
+ val itemView = viewHolder.itemView
+ val backgroundColor = ContextCompat.getColor(itemView.context, R.color.pink_point)
+ val background = ColorDrawable(backgroundColor)
+
+ val icon = ContextCompat.getDrawable(recyclerView.context, R.drawable.ic_delete)!!
+
+ // 배경 설정
+ background.setBounds(
+ itemView.right + dX.toInt(),
+ itemView.top,
+ itemView.right,
+ itemView.bottom
+ )
+ background.draw(c)
+
+ // 아이콘 크기를 아이템 높이의 70%로 설정
+ val itemHeight = itemView.height
+ val iconSize = (itemHeight * 0.6).toInt()
+ val iconMargin = (itemHeight - iconSize) / 2
+
+ val iconTop = itemView.top + iconMargin
+ val iconBottom = iconTop + iconSize
+ val iconLeft = itemView.right - iconMargin - iconSize
+ val iconRight = itemView.right - iconMargin
+
+ icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
+ icon.draw(c)
+
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/challenge/ChallengeAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/challenge/ChallengeAdapter.kt
new file mode 100644
index 0000000..b7e1a8e
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/challenge/ChallengeAdapter.kt
@@ -0,0 +1,85 @@
+package com.example.umc_closit.ui.community.challenge
+
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.TextView
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.battle.ChallengeBattlePreview
+import com.example.umc_closit.databinding.ItemChallengeBinding
+import com.example.umc_closit.utils.FileUtils
+
+class ChallengeAdapter(
+ private val challengeList: List,
+ private val context: Context
+) : RecyclerView.Adapter() {
+
+ class ViewHolder(private val binding: ItemChallengeBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(challenge: ChallengeBattlePreview) {
+
+ // Glide를 이용해 이미지 로드
+ Glide.with(binding.root.context)
+ .load(challenge.firstPostFrontImage)
+ .placeholder(R.drawable.image_background)
+ .into(binding.ivImageBig)
+
+ Glide.with(binding.root.context)
+ .load(challenge.firstPostBackImage)
+ .placeholder(R.drawable.image_background)
+ .into(binding.ivImageSmall)
+
+ var isFrontImageBig = true
+ val fakeTagContainer = ConstraintLayout(binding.root.context)
+
+ binding.ivImageSmall.setOnClickListener {
+ FileUtils.swapImagesWithTagEffect(
+ bigImageView = binding.ivImageBig,
+ smallImageView = binding.ivImageSmall,
+ tagContainer = fakeTagContainer
+ ) {
+ isFrontImageBig = !isFrontImageBig
+ }
+ }
+
+ // Challenge 제목 설정
+ binding.challengeTitle.text = challenge.title
+
+ // 유저네임과 프로필 이미지 설정
+ binding.userName.text = challenge.firstClositId
+ Glide.with(binding.root.context)
+ .load(challenge.firstProfileImage)
+ .placeholder(R.drawable.ic_profile_placeholder)
+ .circleCrop()
+ .into(binding.profileImage)
+
+ // 오른쪽 "도전하기" 카드 클릭 이벤트
+ binding.clChallengeWrapper.setOnClickListener {
+ val intent = Intent(binding.root.context, NewChallengeActivity::class.java)
+ intent.putExtra("challenge_data", challenge)
+ binding.root.context.startActivity(intent)
+ Toast.makeText(binding.root.context, "도전하기!", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val binding = ItemChallengeBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(challengeList[position])
+ }
+
+ override fun getItemCount(): Int = challengeList.size
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/challenge/ChallengeFragment.kt b/app/src/main/java/com/example/umc_closit/ui/community/challenge/ChallengeFragment.kt
new file mode 100644
index 0000000..c8f025a
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/challenge/ChallengeFragment.kt
@@ -0,0 +1,123 @@
+package com.example.umc_closit.ui.community.challenge
+
+import ChallengeApiService
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.example.umc_closit.data.remote.challenge.*
+import com.example.umc_closit.databinding.FragmentChallengeBinding
+import com.example.umc_closit.utils.TokenUtils
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.battle.ChallengeBattleResponse
+import com.example.umc_closit.ui.community.battle.NewBattleActivity
+import com.example.umc_closit.data.remote.battle.BattleApiService
+import com.example.umc_closit.data.remote.battle.ChallengeBattlePreview
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class ChallengeFragment : Fragment() {
+
+ private var _binding: FragmentChallengeBinding? = null
+ private val binding get() = _binding!!
+ private lateinit var challengeAdapter: ChallengeAdapter
+ private val challengeList = mutableListOf() // 실제 데이터 리스트
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentChallengeBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // RecyclerView 초기화
+ binding.ChallengeRecyclerView.layoutManager = LinearLayoutManager(requireContext())
+ challengeAdapter = ChallengeAdapter(challengeList, requireContext())
+ binding.ChallengeRecyclerView.adapter = challengeAdapter
+
+ // 챌린지 데이터 불러오기
+ fetchChallengeBattles()
+
+ // createButton 클릭 시 배틀 챌린지 API 호출 (더미 데이터로)
+ binding.createButton.setOnClickListener {
+ val intent = Intent(requireContext(), NewBattleActivity::class.java)
+ startActivity(intent)
+ }
+ }
+
+ /**
+ * 🔥 챌린지 목록 API 호출
+ */
+ private fun fetchChallengeBattles() {
+ val apiService = RetrofitClient.createService(BattleApiService::class.java)
+
+ TokenUtils.handleTokenRefresh(
+ call = apiService.getChallengeBattles(page = 0),
+ onSuccess = { response ->
+ val result = response as ChallengeBattleResponse
+ if (result.isSuccess && result.result != null) {
+ val challengeList = result.result.challengeBattlePreviewList
+ val adapter = ChallengeAdapter(challengeList, requireContext())
+ binding.ChallengeRecyclerView.adapter = adapter
+ } else {
+ Toast.makeText(requireContext(), "불러오기 실패: ${result.message}", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onFailure = { error ->
+ Toast.makeText(requireContext(), "네트워크 오류: ${error.message}", Toast.LENGTH_SHORT).show()
+ },
+ context = requireContext()
+ )
+ }
+
+ /**
+ * 🔥 배틀 챌린지 API 호출
+ */
+ private fun uploadChallenge(battleId: Int, postId: Int) {
+ val apiService = RetrofitClient.createService(ChallengeApiService::class.java)
+
+ val originalCall = {
+ apiService.uploadChallenge(
+ token = "Bearer ${TokenUtils.getAccessToken(requireContext())}",
+ battleId = battleId,
+ requestBody = ChallengeRequest(postId = postId)
+ )
+ }
+
+ TokenUtils.handleTokenRefresh(
+ call = originalCall(),
+ onSuccess = { response ->
+ val result = response as ChallengeResponse
+ if (result.isSuccess) {
+ Toast.makeText(requireContext(), "배틀 챌린지 신청 성공!", Toast.LENGTH_SHORT).show()
+ println("✅ 배틀 챌린지 성공: ${result.result}")
+ val intent = Intent(requireContext(), NewBattleActivity::class.java)
+ startActivity(intent)
+ } else {
+ Toast.makeText(requireContext(), "실패: ${result.message}", Toast.LENGTH_SHORT).show()
+ println("실패: ${result.message}")
+ }
+ },
+ onFailure = { error ->
+ Toast.makeText(requireContext(), "요청 실패: ${error.message}", Toast.LENGTH_SHORT).show()
+ println("요청 실패: ${error.message}")
+ },
+ context = requireContext()
+ )
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeActivity.kt b/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeActivity.kt
new file mode 100644
index 0000000..9497bfe
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeActivity.kt
@@ -0,0 +1,85 @@
+package com.example.umc_closit.ui.community.challenge
+
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import com.bumptech.glide.Glide
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.battle.ChallengeBattlePreview
+import com.example.umc_closit.data.remote.post.RecentPostResponse
+import com.example.umc_closit.data.remote.post.UserRecentPostDTO
+import com.example.umc_closit.databinding.ActivityMakechallengeBinding
+import com.example.umc_closit.utils.TokenUtils
+import kotlinx.coroutines.launch
+
+class NewChallengeActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMakechallengeBinding
+ private lateinit var adapter: NewChallengeAdapter
+ private val itemList = mutableListOf() // 최근 게시물 리스트
+
+ private lateinit var challengeData: ChallengeBattlePreview
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMakechallengeBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ challengeData = intent.getParcelableExtra("challenge_data") ?: return
+
+ setupRecyclerView()
+
+ binding.ivBack.setOnClickListener {
+ onBackPressed() // 뒤로 가기
+ }
+
+ val myClositId = TokenUtils.getClositId(this) ?: ""
+ fetchRecentPosts(myClositId)
+ }
+
+ private fun setupRecyclerView() {
+ adapter = NewChallengeAdapter(itemList, this, challengeData = intent.getParcelableExtra("challenge_data") ?: return)
+ binding.challengeRecyclerView.layoutManager = GridLayoutManager(this, 3) // 한 줄에 3개
+ binding.challengeRecyclerView.adapter = adapter
+ }
+
+ private fun fetchRecentPosts(clositId: String) {
+ // Call 객체 생성
+ val call = RetrofitClient.postService.getRecentPosts(clositId, 0)
+
+ // 비동기 호출
+ call.enqueue(object : retrofit2.Callback {
+ override fun onResponse(
+ call: retrofit2.Call,
+ response: retrofit2.Response
+ ) {
+ if (response.isSuccessful) {
+ val recentPostResponse = response.body()
+ if (recentPostResponse != null && recentPostResponse.isSuccess) {
+ val posts = recentPostResponse.result.userRecentPostDTOList
+ if (posts.isNotEmpty()) {
+ itemList.clear()
+ itemList.addAll(posts)
+ adapter.notifyDataSetChanged()
+ } else {
+ Toast.makeText(this@NewChallengeActivity, "데이터가 없습니다.", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(this@NewChallengeActivity, "API 실패: ${recentPostResponse?.message}", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Log.e("API_ERROR", "응답 실패: ${response.code()} - ${response.message()}")
+ Toast.makeText(this@NewChallengeActivity, "불러오기 실패", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onFailure(call: retrofit2.Call, t: Throwable) {
+ Log.e("API_ERROR", "네트워크 오류: ${t.message}")
+ Toast.makeText(this@NewChallengeActivity, "네트워크 오류 발생", Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeAdapter.kt
new file mode 100644
index 0000000..9752a0d
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeAdapter.kt
@@ -0,0 +1,51 @@
+package com.example.umc_closit.ui.community.challenge
+
+import android.content.Context
+import android.content.Intent
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.battle.ChallengeBattlePreview
+import com.example.umc_closit.data.remote.post.UserRecentPostDTO
+import com.example.umc_closit.databinding.ItemBattle2Binding
+import com.example.umc_closit.ui.community.challenge.NewChallengeDetailActivity
+
+class NewChallengeAdapter(
+ private val itemList: List,
+ private val context: Context,
+ private val challengeData: ChallengeBattlePreview
+) : RecyclerView.Adapter() {
+
+ inner class ChallengeViewHolder(val binding: ItemBattle2Binding) : RecyclerView.ViewHolder(binding.root)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChallengeViewHolder {
+ val binding = ItemBattle2Binding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return ChallengeViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ChallengeViewHolder, position: Int) {
+ val post = itemList[position]
+
+ // 썸네일 이미지 로드
+ Glide.with(context)
+ .load(post.thumbnail)
+ .placeholder(R.drawable.img_gray_square) // 로딩 중일 때 기본 이미지
+ .error(R.drawable.img_gray_square) // 로딩 실패 시 기본 이미지
+ .centerCrop()
+ .into(holder.binding.imageView)
+
+ // 이미지 클릭 시 NewChallengeDetailActivity 로 이동
+ holder.binding.imageView.setOnClickListener {
+ val intent = Intent(context, NewChallengeDetailActivity::class.java).apply {
+ putExtra("thumbnail_url", post.thumbnail) // 썸네일 URL 전달
+ putExtra("post_id", post.postId) // postId 전달
+ putExtra("challenge_data", challengeData)
+ }
+ context.startActivity(intent)
+ }
+ }
+
+ override fun getItemCount(): Int = itemList.size
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeDetailActivity.kt b/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeDetailActivity.kt
new file mode 100644
index 0000000..3e29864
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/challenge/NewChallengeDetailActivity.kt
@@ -0,0 +1,137 @@
+package com.example.umc_closit.ui.community.challenge
+
+import android.os.Bundle
+import android.util.Log
+import android.widget.ImageButton
+import android.widget.ImageView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.battle.BattleChallengeRequest
+import com.example.umc_closit.data.remote.battle.BattleChallengeResponse
+import com.example.umc_closit.data.remote.battle.ChallengeBattlePreview
+import com.example.umc_closit.utils.TokenUtils
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+import java.text.SimpleDateFormat
+import java.util.*
+
+class NewChallengeDetailActivity : AppCompatActivity() {
+
+ private lateinit var ivImage1Big: ImageView
+ private lateinit var ivImage2Big: ImageView
+ private lateinit var btnUpload: ImageButton
+
+ private lateinit var challengeData: ChallengeBattlePreview
+ private var thumbnailUrl: String? = null
+ private var postId: Int = -1
+ private var myClositId: String = ""
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_new_challenge_detail)
+
+ // View 초기화
+ ivImage1Big = findViewById(R.id.iv_image1_big)
+ ivImage2Big = findViewById(R.id.iv_image2_big)
+ btnUpload = findViewById(R.id.btnUpload)
+
+ // Intent로부터 데이터 수신
+ thumbnailUrl = intent.getStringExtra("thumbnail_url")
+ postId = intent.getIntExtra("post_id", -1)
+ challengeData = intent.getParcelableExtra("challenge_data") ?: return
+
+ // 현재 사용자의 ClositId 가져오기
+ myClositId = TokenUtils.getClositId(this) ?: ""
+
+ // 이미지 로드
+ loadImages()
+
+ // 업로드 버튼 클릭 이벤트
+ btnUpload.setOnClickListener {
+ if (postId != -1 && thumbnailUrl != null) {
+ uploadChallenge()
+ } else {
+ Toast.makeText(this, "업로드할 데이터를 불러올 수 없습니다.", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ /**
+ * 이미지 로드
+ */
+ private fun loadImages() {
+ // 첫 번째 이미지: challengeData.firstPostFrontImage
+ Glide.with(this)
+ .load(challengeData.firstPostFrontImage)
+ .placeholder(R.drawable.img_detail_big_default)
+ .error(R.drawable.img_detail_big_default)
+ .into(ivImage1Big)
+
+ // 두 번째 이미지: thumbnailUrl
+ Glide.with(this)
+ .load(thumbnailUrl)
+ .placeholder(R.drawable.img_detail_big_default)
+ .error(R.drawable.img_detail_big_default)
+ .into(ivImage2Big)
+ }
+
+ /**
+ * 배틀 도전 API 호출
+ */
+ private fun uploadChallenge() {
+ // BattleChallengeRequest 생성
+ val request = BattleChallengeRequest(
+ postId = postId
+ )
+
+ // API 호출
+ RetrofitClient.battleApiService.challengeBattle(challengeData.battleId, request)
+ .enqueue(object : Callback {
+ override fun onResponse(
+ call: Call,
+ response: Response
+ ) {
+ if (response.isSuccessful) {
+ val result = response.body()
+ if (result != null && result.isSuccess) {
+ Toast.makeText(
+ this@NewChallengeDetailActivity,
+ "배틀 도전 성공!",
+ Toast.LENGTH_SHORT
+ ).show()
+ finish()
+ } else {
+ Toast.makeText(
+ this@NewChallengeDetailActivity,
+ "도전 실패: ${result?.message}",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ } else {
+ val errorBody = response.errorBody()?.string()
+ Log.e("API_ERROR", "❌ 실패 응답 코드: ${response.code()} - ${response.message()}")
+ Log.e("API_ERROR", "❌ 에러 바디: $errorBody")
+
+ Toast.makeText(
+ this@NewChallengeDetailActivity,
+ "도전 실패: 서버 에러",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ Log.e("API_ERROR", "네트워크 오류: ${t.message}")
+ Toast.makeText(
+ this@NewChallengeDetailActivity,
+ "네트워크 오류가 발생했습니다.",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/todaycloset/TodayClosetAdapter.kt b/app/src/main/java/com/example/umc_closit/ui/community/todaycloset/TodayClosetAdapter.kt
new file mode 100644
index 0000000..f7826c7
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/todaycloset/TodayClosetAdapter.kt
@@ -0,0 +1,76 @@
+package com.example.umc_closit.ui.community.todaycloset
+
+import android.content.Intent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.battle.TodayClosetItem
+import com.example.umc_closit.ui.timeline.detail.DetailActivity
+
+class TodayClosetAdapter : RecyclerView.Adapter() {
+
+ private val itemList = mutableListOf()
+
+ class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ val frontImage: ImageView = view.findViewById(R.id.img_front)
+ val backImage: ImageView = view.findViewById(R.id.img_back)
+ val profileImage: ImageView = view.findViewById(R.id.iv_user_profile)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_today_closet_list, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = itemList[position]
+
+ // 전면 사진 로드
+ Glide.with(holder.itemView.context)
+ .load(item.frontImage)
+ .placeholder(R.drawable.ic_placeholder)
+ .into(holder.frontImage)
+
+ // 후면 사진 로드
+ Glide.with(holder.itemView.context)
+ .load(item.backImage)
+ .placeholder(R.drawable.ic_placeholder)
+ .into(holder.backImage)
+
+ // 프로필 사진 로드
+ Glide.with(holder.itemView.context)
+ .load(item.profileImage)
+ .placeholder(R.drawable.img_profile_default)
+ .circleCrop()
+ .into(holder.profileImage)
+
+
+ // 아이템 클릭 시 상세 화면으로 이동
+ holder.itemView.setOnClickListener {
+ val context = holder.itemView.context
+ val intent = Intent(context, DetailActivity::class.java)
+ intent.putExtra("postId", item.postId)
+ context.startActivity(intent)
+ }
+ }
+
+ override fun getItemCount(): Int = itemList.size
+
+ fun submitList(list: List) {
+ itemList.clear()
+ itemList.addAll(list)
+ notifyDataSetChanged()
+ }
+
+ fun addItems(list: List) {
+ val currentSize = itemList.size
+ itemList.addAll(list)
+ notifyItemRangeInserted(currentSize, list.size)
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/community/todaycloset/TodayClosetFragment.kt b/app/src/main/java/com/example/umc_closit/ui/community/todaycloset/TodayClosetFragment.kt
new file mode 100644
index 0000000..c0713f5
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/community/todaycloset/TodayClosetFragment.kt
@@ -0,0 +1,114 @@
+package com.example.umc_closit.ui.community.todaycloset
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.databinding.FragmentTodayclosetBinding
+import com.example.umc_closit.ui.upload.UploadActivity
+import com.example.umc_closit.utils.TokenUtils
+
+class TodayClosetFragment : Fragment() {
+
+ private var _binding: FragmentTodayclosetBinding? = null
+ private val binding get() = _binding!!
+ private lateinit var adapter: TodayClosetAdapter
+
+ // 페이징 변수
+ private var currentPage = 0
+ private var isLoading = false
+ private var hasNext = true
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentTodayclosetBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // RecyclerView 초기화 (2열 그리드)
+ adapter = TodayClosetAdapter()
+ binding.recyclerTodaycloset.layoutManager = GridLayoutManager(requireContext(), 2)
+ binding.recyclerTodaycloset.adapter = adapter
+
+ // 첫 페이지 데이터 불러오기
+ loadTodayClosets(currentPage)
+
+ // 스크롤 페이징 처리
+ binding.recyclerTodaycloset.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ val layoutManager = recyclerView.layoutManager as GridLayoutManager
+ val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
+ if (lastVisibleItem == adapter.itemCount - 1 && hasNext && !isLoading) {
+ loadTodayClosets(currentPage + 1)
+ }
+ }
+ })
+
+ // 업로드 버튼 클릭 시 UploadActivity로 이동
+ binding.createButton.setOnClickListener {
+ val intent = Intent(requireContext(), UploadActivity::class.java)
+ startActivity(intent)
+ }
+ }
+
+ /**
+ * 오늘의 옷장 API 호출
+ */
+ private fun loadTodayClosets(page: Int) {
+ isLoading = true
+
+ TokenUtils.handleTokenRefresh(
+ call = RetrofitClient.todayClosetApiService.getTodayClosets(page),
+ onSuccess = { response ->
+ isLoading = false
+ Log.d("TodayCloset API Response", response.toString()) // API 응답 로그 출력
+
+ if (response.isSuccess) {
+ hasNext = response.result.hasNext
+ currentPage = page
+
+ if (response.result.todayClosets.isEmpty()) {
+ Log.d("TodayCloset", "서버에서 받은 게시글이 없음")
+ } else {
+ Log.d("TodayCloset", "서버에서 받은 게시글 개수: ${response.result.todayClosets.size}")
+ }
+
+ if (page == 1) {
+ adapter.submitList(response.result.todayClosets)
+ } else {
+ adapter.addItems(response.result.todayClosets)
+ }
+ } else {
+ Log.e("TodayCloset", "API 응답 오류: ${response.message}")
+ Toast.makeText(requireContext(), "데이터 로드 실패: ${response.message}", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onFailure = { throwable ->
+ isLoading = false
+ Log.e("TodayCloset", "API 호출 실패", throwable)
+ Toast.makeText(requireContext(), "네트워크 오류", Toast.LENGTH_SHORT).show()
+ },
+ context = requireContext()
+ )
+ }
+
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/LoginActivity.kt b/app/src/main/java/com/example/umc_closit/ui/login/LoginActivity.kt
new file mode 100644
index 0000000..3e0ba51
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/LoginActivity.kt
@@ -0,0 +1,147 @@
+package com.example.umc_closit.ui.login
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.InputType
+import android.util.Log
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContentProviderCompat.requireContext
+import androidx.core.content.res.ResourcesCompat
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.auth.LoginRequest
+import com.example.umc_closit.data.remote.auth.LoginResponse
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.databinding.ActivityLoginBinding
+import com.example.umc_closit.ui.login.find.FindIDActivity
+import com.example.umc_closit.ui.login.find.FindPasswordActivity
+import com.example.umc_closit.ui.timeline.TimelineActivity
+import com.example.umc_closit.utils.TokenUtils
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class LoginActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityLoginBinding
+ private var isPasswordVisible = false // 비밀번호 표시 여부
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ checkLoginStatus() // 로그인 체크
+
+
+ binding.btnLogin.setOnClickListener {
+ val email = binding.passwordContainer.text.toString().trim()
+ val password = binding.etPassword.text.toString().trim()
+
+ if (email.isEmpty() || password.isEmpty()) {
+ Toast.makeText(this, "이메일과 비밀번호를 입력해주세요.", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ loginUser(email, password)
+ }
+
+ // 비밀번호 보기/숨기기 토글 기능
+ binding.btnTogglePassword.setOnClickListener {
+ togglePasswordVisibility()
+ }
+
+ // 회원가입 버튼 클릭 이벤트
+ binding.btnRegister.setOnClickListener {
+ val intent = Intent(this, RegisterActivity::class.java)
+ startActivity(intent)
+ }
+
+ // 아이디 찾기 버튼 클릭 이벤트
+ binding.btnFindId.setOnClickListener {
+ val intent = Intent(this, FindIDActivity::class.java)
+ startActivity(intent)
+ }
+
+ // 비밀번호 찾기 버튼 클릭 이벤트
+ binding.btnFindPassword.setOnClickListener {
+ val intent = Intent(this, FindPasswordActivity::class.java)
+ startActivity(intent)
+ }
+ }
+
+ private fun togglePasswordVisibility() {
+ if (isPasswordVisible) {
+ // 숨김 상태
+ binding.etPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ binding.btnTogglePassword.setImageResource(R.drawable.ic_eye_off)
+ } else {
+ // 표시 상태
+ binding.etPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
+ binding.btnTogglePassword.setImageResource(R.drawable.ic_eye)
+ }
+
+ val typeface = ResourcesCompat.getFont(binding.root.context, R.font.noto_regular)
+ binding.etPassword.typeface = typeface
+
+ isPasswordVisible = !isPasswordVisible
+ binding.etPassword.setSelection(binding.etPassword.text.length)
+ }
+
+ private fun loginUser(email: String, password: String) {
+ val request = LoginRequest(email, password)
+
+ RetrofitClient.authService.loginUser(request).enqueue(object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ if (response.isSuccessful) {
+ val result = response.body()
+ Log.d("LOGIN_SUCCESS", "응답: $result")
+
+ if (result != null && result.isSuccess) {
+ val accessToken = result.result?.accessToken ?: ""
+ val refreshToken = result.result?.refreshToken ?: ""
+ val clositId = result.result?.clositId ?: ""
+
+ // 토큰 및 Closit ID 저장
+ TokenUtils.saveTokens(this@LoginActivity, accessToken, refreshToken, clositId)
+
+ // 로그인 성공 후 타임라인 이동
+ startActivity(Intent(this@LoginActivity, TimelineActivity::class.java))
+ Log.d("TOKEN_DEBUG", "로그인 성공 후 AccessToken: $accessToken")
+ Log.d("TOKEN_DEBUG", "로그인 성공 후 RefreshToken: $refreshToken")
+ finish()
+ } else {
+ Toast.makeText(this@LoginActivity, "로그인 실패: ${result?.message}", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ when (response.code()) {
+ 400 -> Toast.makeText(this@LoginActivity, "이메일과 비밀번호를 올바르게 입력해주세요.", Toast.LENGTH_SHORT).show()
+ 404 -> Toast.makeText(this@LoginActivity, "존재하지 않는 회원입니다.", Toast.LENGTH_SHORT).show()
+ else -> {
+ Log.e("LOGIN_ERROR", "서버 오류: ${response.code()}, 메시지: ${response.errorBody()?.string()}")
+ Toast.makeText(this@LoginActivity, "서버 오류: ${response.code()}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ Log.e("LOGIN_ERROR", "네트워크 오류: ${t.message}")
+ Toast.makeText(this@LoginActivity, "네트워크 오류: ${t.message}", Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+
+
+ // 자동 로그인 기능 추가
+ private fun checkLoginStatus() {
+ val isLoggedIn = TokenUtils.isLoggedIn(this)
+
+ if (isLoggedIn) {
+ Log.d("AUTO_LOGIN", "자동 로그인 진행 중...")
+ startActivity(Intent(this, TimelineActivity::class.java))
+ finish()
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/MakeIdActivity.kt b/app/src/main/java/com/example/umc_closit/ui/login/MakeIdActivity.kt
new file mode 100644
index 0000000..bdea96b
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/MakeIdActivity.kt
@@ -0,0 +1,219 @@
+package com.example.umc_closit.ui.login
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.BaseResponse
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.auth.CheckIdResponse
+import com.example.umc_closit.databinding.ActivityMakeidBinding
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class MakeIdActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMakeidBinding
+ private var emailValidationFailed = false
+ private var birthDateValidationFailed = false
+ private var isIdChecked = false // ID 중복 확인 완료 여부
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMakeidBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 뒤로 가기 버튼
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+
+ // 초기에 버튼 비활성화 및 오류 메시지 숨김
+ binding.btnNext.isEnabled = false
+ binding.btnNext.backgroundTintList = ContextCompat.getColorStateList(this, R.color.gray_dark)
+ binding.tvErrorEmail.visibility = View.GONE
+ binding.tvErrorBirthdate.visibility = View.GONE
+ binding.tvErrorId.visibility = View.GONE
+
+ setupTextWatchers()
+ setupFocusListeners()
+
+
+ binding.btnCheckId.setOnClickListener {
+ val inputId = binding.etId.text.toString().trim()
+
+ if (inputId.isEmpty()) {
+ Toast.makeText(this@MakeIdActivity, "ID를 입력해주세요.", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ RetrofitClient.authService.checkIdUnique(inputId).enqueue(object : Callback> {
+ override fun onResponse(call: Call>, response: Response>) {
+ if (response.isSuccessful) {
+ val result = response.body()?.result ?: false
+
+ if (result) {
+ binding.etId.isEnabled = false
+ binding.btnCheckId.isEnabled = false
+ binding.etId.setBackgroundResource(R.drawable.edittext_rounded_checked)
+ binding.tvErrorId.visibility = View.GONE
+
+ isIdChecked = true // ✅ ID 중복 확인 완료
+ validateInputs() // 입력 검증 다시 호출해서 버튼 활성화 반영
+ } else {
+ binding.tvErrorId.visibility = View.VISIBLE
+ isIdChecked = false
+ validateInputs()
+ }
+ } else {
+ Toast.makeText(this@MakeIdActivity, "서버 오류: ${response.code()}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onFailure(call: Call>, t: Throwable) {
+ Toast.makeText(this@MakeIdActivity, "네트워크 오류: ${t.message}", Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+
+
+ binding.btnNext.setOnClickListener {
+ val name = binding.etName.text.toString()
+ val userId = binding.etId.text.toString()
+ val email = binding.etEmail.text.toString()
+ val birthDate = binding.etBirthdate.text.toString()
+
+ // 입력값 검증
+ if (name.isBlank() || userId.isBlank() || email.isBlank() || birthDate.isBlank()) {
+ Toast.makeText(this, "모든 항목을 입력해주세요.", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ if (!isIdChecked) {
+ Toast.makeText(this, "ID 중복 확인을 완료해주세요.", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ val intent = Intent(this, PasswordActivity::class.java).apply {
+ putExtra("name", name)
+ putExtra("userId", userId)
+ putExtra("email", email)
+ putExtra("birthDate", birthDate)
+ }
+ startActivity(intent)
+ }
+ }
+
+ private fun setupTextWatchers() {
+ binding.etId.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ // ID가 바뀌면 다시 확인하도록 초기화
+ isIdChecked = false
+ binding.etId.isEnabled = true
+ binding.btnCheckId.isEnabled = true
+ binding.etId.setBackgroundResource(R.drawable.edittext_rounded)
+
+ validateInputs()
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+
+ binding.etEmail.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ val email = binding.etEmail.text.toString().trim()
+ if (isValidEmail(email)) {
+ emailValidationFailed = false
+ binding.tvErrorEmail.visibility = View.GONE
+ }
+ validateInputs()
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+
+ binding.etBirthdate.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ val birthDate = binding.etBirthdate.text.toString().trim()
+ if (isValidBirthDate(birthDate)) {
+ birthDateValidationFailed = false
+ binding.tvErrorBirthdate.visibility = View.GONE
+ }
+ validateInputs()
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+ }
+
+ private fun setupFocusListeners() {
+ binding.etEmail.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) { // 포커스를 잃으면 유효성 검증 실행
+ val email = binding.etEmail.text.toString().trim()
+ if (!isValidEmail(email)) {
+ emailValidationFailed = true
+ binding.tvErrorEmail.visibility = View.VISIBLE
+ }
+ validateInputs()
+ } else if (emailValidationFailed) {
+ binding.tvErrorEmail.visibility = View.VISIBLE // 포커스를 다시 얻어도 유지
+ }
+ }
+
+ binding.etBirthdate.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) { // 포커스를 잃으면 유효성 검증 실행
+ val birthDate = binding.etBirthdate.text.toString().trim()
+ if (!isValidBirthDate(birthDate)) {
+ birthDateValidationFailed = true
+ binding.tvErrorBirthdate.visibility = View.VISIBLE
+ }
+ validateInputs()
+ } else if (birthDateValidationFailed) {
+ binding.tvErrorBirthdate.visibility = View.VISIBLE // 포커스를 다시 얻어도 유지
+ }
+ }
+ }
+
+ private fun validateInputs() {
+ val name = binding.etName.text.toString().trim()
+ val userId = binding.etId.text.toString().trim()
+ val email = binding.etEmail.text.toString().trim()
+ val birthDate = binding.etBirthdate.text.toString().trim()
+
+ val isAllValid = name.isNotEmpty() &&
+ userId.isNotEmpty() &&
+ email.isNotEmpty() &&
+ birthDate.isNotEmpty() &&
+ !emailValidationFailed &&
+ !birthDateValidationFailed &&
+ isIdChecked
+
+ binding.btnNext.isEnabled = isAllValid
+
+ val color = if (isAllValid) R.color.black else R.color.gray_dark
+ binding.btnNext.backgroundTintList = ContextCompat.getColorStateList(this, color)
+ }
+
+ // 이메일 유효성 검사
+ private fun isValidEmail(email: String): Boolean {
+ val emailPattern = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"
+ return email.matches(emailPattern.toRegex())
+ }
+
+ // 생년월일 유효성 검사 (YYYY-MM-DD 형식)
+ private fun isValidBirthDate(birthDate: String): Boolean {
+ val datePattern = "^\\d{4}-\\d{2}-\\d{2}$"
+ return birthDate.matches(datePattern.toRegex())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/PasswordActivity.kt b/app/src/main/java/com/example/umc_closit/ui/login/PasswordActivity.kt
new file mode 100644
index 0000000..3e97000
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/PasswordActivity.kt
@@ -0,0 +1,204 @@
+package com.example.umc_closit.ui.login
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import com.example.umc_closit.data.remote.auth.RegisterRequest
+import com.example.umc_closit.data.remote.auth.RegisterResponse
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.databinding.ActivityPasswordBinding
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class PasswordActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityPasswordBinding
+ private var passwordValidationFailed = false
+ private var confirmPasswordValidationFailed = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityPasswordBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val name = intent.getStringExtra("name") ?: ""
+ val userId = intent.getStringExtra("userId") ?: ""
+ val email = intent.getStringExtra("email") ?: ""
+ val birthDate = intent.getStringExtra("birthDate") ?: ""
+
+ // 초기 버튼 비활성화 및 오류 메시지 숨김
+ binding.btnSetCredentials.isEnabled = false
+ binding.tvErrorPw.visibility = View.GONE
+ binding.tvErrorCfpw.visibility = View.GONE
+
+ setupTextWatchers()
+ setupFocusListeners()
+
+ binding.btnSetCredentials.setOnClickListener {
+ validateAndRegister(name, userId, email, birthDate)
+ }
+
+ // 뒤로가기 버튼
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+ }
+
+ private fun setupTextWatchers() {
+ binding.etNewPassword.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ val password = binding.etNewPassword.text.toString().trim()
+ if (isValidPassword(password)) {
+ passwordValidationFailed = false
+ binding.tvErrorPw.visibility = View.GONE
+ }
+ validateInputs()
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+
+ binding.etConfirmPassword.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ val password = binding.etNewPassword.text.toString().trim()
+ val confirmPassword = binding.etConfirmPassword.text.toString().trim()
+ if (confirmPassword.isNotEmpty() && password == confirmPassword) {
+ confirmPasswordValidationFailed = false
+ binding.tvErrorCfpw.visibility = View.GONE
+ }
+ validateInputs()
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+ }
+
+ private fun setupFocusListeners() {
+ binding.etNewPassword.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) { // 포커스를 잃으면 유효성 검증 실행
+ val password = binding.etNewPassword.text.toString().trim()
+ if (!isValidPassword(password)) {
+ passwordValidationFailed = true
+ binding.tvErrorPw.visibility = View.VISIBLE
+ }
+ validateInputs()
+ } else if (passwordValidationFailed) {
+ binding.tvErrorPw.visibility = View.VISIBLE // 포커스를 다시 얻어도 유지
+ }
+ }
+
+ binding.etConfirmPassword.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) { // 포커스를 잃으면 유효성 검증 실행
+ val password = binding.etNewPassword.text.toString().trim()
+ val confirmPassword = binding.etConfirmPassword.text.toString().trim()
+ if (confirmPassword.isBlank() || password != confirmPassword) {
+ confirmPasswordValidationFailed = true
+ binding.tvErrorCfpw.visibility = View.VISIBLE
+ }
+ validateInputs()
+ } else if (confirmPasswordValidationFailed) {
+ binding.tvErrorCfpw.visibility = View.VISIBLE // 포커스를 다시 얻어도 유지
+ }
+ }
+ }
+
+ private fun validateInputs() {
+ val password = binding.etNewPassword.text.toString().trim()
+ val confirmPassword = binding.etConfirmPassword.text.toString().trim()
+
+ val isAllValid = password.isNotEmpty() &&
+ confirmPassword.isNotEmpty() &&
+ !passwordValidationFailed &&
+ !confirmPasswordValidationFailed
+
+ binding.btnSetCredentials.isEnabled = isAllValid
+ }
+
+ private fun validateAndRegister(name: String, userId: String, email: String, birthDate: String) {
+ val password = binding.etNewPassword.text.toString().trim()
+ val confirmPassword = binding.etConfirmPassword.text.toString().trim()
+
+ var isValid = true
+
+ binding.tvErrorPw.visibility = View.GONE
+ binding.tvErrorCfpw.visibility = View.GONE
+
+ if (!isValidPassword(password)) {
+ binding.tvErrorPw.text = "비밀번호는 최소 8자리 이상이어야 합니다"
+ binding.tvErrorPw.visibility = View.VISIBLE
+ isValid = false
+ }
+
+ if (confirmPassword.isBlank() || password != confirmPassword) {
+ binding.tvErrorCfpw.text = "비밀번호가 일치하지 않습니다"
+ binding.tvErrorCfpw.visibility = View.VISIBLE
+ isValid = false
+ }
+
+ if (!isValid) return
+
+ // 모든 조건 충족 시 회원가입 API 호출
+ registerUser(name, userId, email, birthDate)
+ }
+
+ private fun registerUser(name: String, userId: String, email: String, birthDate: String) {
+ val password = binding.etNewPassword.text.toString().trim()
+ val request = RegisterRequest(
+ name = name,
+ email = email,
+ password = password,
+ clositId = userId,
+ birth = birthDate,
+ profileImage = "android.resource://com.example.umc_closit/drawable/img_profile_default"
+ )
+
+ Log.d("API_REQUEST", "보내는 데이터: $request")
+
+ RetrofitClient.authService.registerUser(request).enqueue(object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ Log.d("API_RESPONSE", "응답 코드: ${response.code()}")
+
+ if (response.isSuccessful) {
+ val result = response.body()
+ Log.d("API_RESPONSE", "응답 본문: $result")
+
+ if (result != null && result.isSuccess) {
+ val clositId = result.result?.clositId ?: ""
+ val name = result.result?.name ?: ""
+ val email = result.result?.email ?: ""
+
+ Log.d("REGISTER_SUCCESS", "clositId: $clositId, name: $name, email: $email")
+
+ startActivity(Intent(this@PasswordActivity, LoginActivity::class.java))
+ finish()
+ } else {
+ Toast.makeText(this@PasswordActivity, "회원가입 실패: ${result?.message}", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Log.e("API_ERROR", "서버 오류: ${response.code()}, 메시지: ${response.errorBody()?.string()}")
+ Toast.makeText(this@PasswordActivity, "서버 오류: ${response.code()}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ Log.e("API_ERROR", "네트워크 오류: ${t.message}")
+ Toast.makeText(this@PasswordActivity, "네트워크 오류: ${t.message}", Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+
+
+ // 비밀번호 유효성 검사 (최소 8자 이상)
+ private fun isValidPassword(password: String): Boolean {
+ return password.length >= 8
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/RegisterActivity.kt b/app/src/main/java/com/example/umc_closit/ui/login/RegisterActivity.kt
new file mode 100644
index 0000000..378c836
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/RegisterActivity.kt
@@ -0,0 +1,86 @@
+package com.example.umc_closit.ui.login
+
+import android.content.Intent
+import android.os.Bundle
+import android.widget.CompoundButton
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import com.example.umc_closit.R
+import com.example.umc_closit.databinding.ActivityRegisterBinding
+
+class RegisterActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityRegisterBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityRegisterBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 약관 내용 불러오기
+ loadTerms()
+
+ // 다음 버튼 클릭 이벤트
+ binding.btnNext.setOnClickListener {
+ val intent = Intent(this, MakeIdActivity::class.java)
+ startActivity(intent)
+ }
+
+ // 뒤로가기 버튼 클릭 이벤트
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+
+ // 초기 버튼 비활성화 & 회색 설정
+ binding.btnNext.isEnabled = false
+ binding.btnNext.backgroundTintList = ContextCompat.getColorStateList(this, R.color.gray_dark)
+
+ setupCheckListeners()
+ }
+
+ private lateinit var allCheckListener: CompoundButton.OnCheckedChangeListener
+
+ private fun setupCheckListeners() {
+ val checkBoxes = listOf(binding.chkRegister1, binding.chkRegister2, binding.chkRegister3)
+
+ val checkListener = CompoundButton.OnCheckedChangeListener { _, _ ->
+ validateChecks()
+ }
+
+ allCheckListener = CompoundButton.OnCheckedChangeListener { _, isChecked ->
+ checkBoxes.forEach { it.isChecked = isChecked }
+ }
+
+ checkBoxes.forEach { it.setOnCheckedChangeListener(checkListener) }
+
+ binding.chkRegisterAll.setOnCheckedChangeListener(allCheckListener)
+ }
+
+ private fun validateChecks() {
+ val allRequiredChecked = binding.chkRegister1.isChecked && binding.chkRegister2.isChecked
+ val allChecked = allRequiredChecked && binding.chkRegister3.isChecked
+
+ binding.btnNext.isEnabled = allRequiredChecked
+
+ val colorRes = if (allRequiredChecked) R.color.black else R.color.gray_dark
+ binding.btnNext.backgroundTintList = ContextCompat.getColorStateList(this, colorRes)
+
+ // 모든 약관 동의 체크박스 상태 변경
+ binding.chkRegisterAll.setOnCheckedChangeListener(null) // 무한루프 방지
+ binding.chkRegisterAll.isChecked = allChecked
+ binding.chkRegisterAll.setOnCheckedChangeListener(allCheckListener)
+ }
+
+
+
+ // assets 폴더에서 약관 텍스트 읽어와서 표시하는 함수
+ private fun loadTerms() {
+ binding.txtRegister1.text = loadTextFromAssets("terms_of_service.txt")
+ binding.txtRegister2.text = loadTextFromAssets("privacy_policy.txt")
+ binding.txtRegister3.text = loadTextFromAssets("marketing_agreement.txt")
+ }
+
+ private fun loadTextFromAssets(fileName: String): String {
+ return assets.open(fileName).bufferedReader().use { it.readText() }
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/find/FindID2Activity.kt b/app/src/main/java/com/example/umc_closit/ui/login/find/FindID2Activity.kt
new file mode 100644
index 0000000..6059f06
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/find/FindID2Activity.kt
@@ -0,0 +1,28 @@
+package com.example.umc_closit.ui.login.find
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.example.umc_closit.databinding.ActivityFindid2Binding
+import com.example.umc_closit.ui.login.LoginActivity
+
+class FindID2Activity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityFindid2Binding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityFindid2Binding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 다음 버튼 클릭 이벤트
+ binding.btnGotologin.setOnClickListener {
+ val intent = Intent(this, LoginActivity::class.java)
+ startActivity(intent)
+ }
+ // 뒤로 가기 버튼
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/find/FindIDActivity.kt b/app/src/main/java/com/example/umc_closit/ui/login/find/FindIDActivity.kt
new file mode 100644
index 0000000..0b7e6b6
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/find/FindIDActivity.kt
@@ -0,0 +1,28 @@
+package com.example.umc_closit.ui.login.find
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.example.umc_closit.databinding.ActivityFindidBinding
+
+class FindIDActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityFindidBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityFindidBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 다음 버튼 클릭 이벤트
+ binding.btnNext.setOnClickListener {
+ val intent = Intent(this, FindID2Activity::class.java)
+ startActivity(intent)
+ }
+
+ // 뒤로 가기 버튼
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/find/FindPassword2Activity.kt b/app/src/main/java/com/example/umc_closit/ui/login/find/FindPassword2Activity.kt
new file mode 100644
index 0000000..fcadcd9
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/find/FindPassword2Activity.kt
@@ -0,0 +1,28 @@
+package com.example.umc_closit.ui.login.find
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.example.umc_closit.databinding.ActivityFindpassword2Binding
+import com.example.umc_closit.ui.login.LoginActivity
+
+class FindPassword2Activity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityFindpassword2Binding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityFindpassword2Binding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 다음 버튼 클릭 이벤트
+ binding.btnNext2.setOnClickListener {
+ val intent = Intent(this, LoginActivity::class.java)
+ startActivity(intent)
+ }
+ // 뒤로 가기 버튼
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/login/find/FindPasswordActivity.kt b/app/src/main/java/com/example/umc_closit/ui/login/find/FindPasswordActivity.kt
new file mode 100644
index 0000000..3611f3d
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/login/find/FindPasswordActivity.kt
@@ -0,0 +1,27 @@
+package com.example.umc_closit.ui.login.find
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.example.umc_closit.databinding.ActivityFindpasswordBinding
+
+class FindPasswordActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityFindpasswordBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityFindpasswordBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 다음 버튼 클릭 이벤트
+ binding.btnNext2.setOnClickListener {
+ val intent = Intent(this, FindPassword2Activity::class.java)
+ startActivity(intent)
+ }
+ // 뒤로 가기 버튼
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/mission/BackOnlyActivity.kt b/app/src/main/java/com/example/umc_closit/ui/mission/BackOnlyActivity.kt
new file mode 100644
index 0000000..40630b1
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/mission/BackOnlyActivity.kt
@@ -0,0 +1,395 @@
+package com.example.umc_closit.ui.mission
+
+import android.app.Dialog
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.util.Log
+import android.view.MotionEvent
+import android.view.View
+import android.widget.ArrayAdapter
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.PopupMenu
+import androidx.constraintlayout.helper.widget.Flow
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.lifecycle.Observer
+import androidx.lifecycle.lifecycleScope
+import com.example.mission.utils.RotateBitmap.rotateBitmapIfNeeded
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.post.ItemTag
+import com.example.umc_closit.data.remote.post.PostRequest
+import com.example.umc_closit.data.remote.post.PostService
+import com.example.umc_closit.data.remote.post.TagData
+import com.example.umc_closit.databinding.ActivityBackOnlyBinding
+import com.example.umc_closit.databinding.CustomTagDialogBinding
+import com.example.umc_closit.model.PostViewModel
+import com.example.umc_closit.ui.timeline.TimelineActivity
+import com.example.umc_closit.utils.JsonUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.asRequestBody
+import java.io.File
+import java.util.UUID
+
+class BackOnlyActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityBackOnlyBinding
+ private var originalBitmap: Bitmap? = null
+
+ private var tvPrivacyStatus: TextView? = null // 공개범위 TextView
+
+ private var hashtags: ArrayList = arrayListOf()
+ private var pointColor: Int = -1
+ private var frontTagList: ArrayList? = null
+
+ private var backTagList: ArrayList? = null
+
+ private lateinit var postService: PostService
+
+ private val viewModel: PostViewModel by viewModels()
+
+ companion object {
+ private const val TAGGING_REQUEST_CODE = 1001
+ }
+
+ private fun getDisplayTag(fullTag: String): String {
+ return if (fullTag.length > 7) fullTag.substring(0, 7) + "..." else fullTag
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityBackOnlyBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ postService = RetrofitClient.postService
+
+ hashtags = intent.getStringArrayListExtra("hashtags") ?: arrayListOf()
+ pointColor = intent.getIntExtra("pointColor", -1)
+ frontTagList = intent.getParcelableArrayListExtra("frontTagList") ?: arrayListOf()
+
+ binding.ivBack.setOnClickListener {
+ finish()
+ }
+
+ val backPhotoPath = intent.getStringExtra("backPhotoPath")
+ backPhotoPath?.let { path ->
+ originalBitmap = rotateBitmapIfNeeded(path)
+ originalBitmap?.let { bmp ->
+ binding.imageViewBackOnly.setImageBitmap(bmp)
+ // 배경 이미지에도 동일한 이미지 설정
+ binding.imageViewBackOnlyBackground.setImageBitmap(bmp)
+ }
+ }
+
+ // Upload 보낼 frontPhotoPath
+ val frontPhotoPath = intent.getStringExtra("frontPhotoPath")
+
+ var isColorExtractMode = false
+
+ if (pointColor != -1) {
+ setIconColor(binding.viewColorIcon, pointColor)
+ }
+
+ if (hashtags.isNotEmpty()) {
+ hashtags.forEach { hashtag ->
+ createHashtagTextView(hashtag, binding.clHashtag, binding.flowHashtagContainer)
+ }
+ }
+
+ binding.viewColorIcon.setOnClickListener {
+ isColorExtractMode = !isColorExtractMode
+ }
+
+ // ViewModel의 업로드 결과 관찰
+ viewModel.uploadResult.observe(this, Observer { result ->
+ result.onSuccess { response ->
+ Toast.makeText(this, "게시글 업로드 성공! ID: ${response.result.postId}", Toast.LENGTH_SHORT).show()
+ // 업로드 성공 시 타임라인으로 이동
+ val intent = Intent(this, TimelineActivity::class.java)
+ intent.putExtra("showUploadFragment", true)
+ startActivity(intent)
+ finish()
+ }.onFailure { error ->
+ Toast.makeText(this, "업로드 실패: ${error.message}", Toast.LENGTH_LONG).show()
+ }
+ })
+
+ binding.btnUpload.setOnClickListener {
+ if (frontPhotoPath.isNullOrEmpty() || backPhotoPath.isNullOrEmpty()) {
+ Toast.makeText(this, "이미지 경로를 확인하세요.", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ uploadFullPost(frontPhotoPath, backPhotoPath)
+ }
+
+ binding.addItem.setOnClickListener{
+ val intent = Intent(this, TaggingActivity::class.java).apply {
+ putExtra("photoPath", backPhotoPath)
+ }
+ startActivityForResult(intent, TAGGING_REQUEST_CODE)
+ }
+
+
+ binding.imageViewBackOnly.setOnTouchListener { _, event ->
+ if (isColorExtractMode) {
+ if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_MOVE) {
+ originalBitmap?.let { bmp ->
+ val color = getTouchedColor(bmp, event.x, event.y)
+ setIconColor(binding.viewColorIcon, color)
+ pointColor = color
+ }
+ isColorExtractMode = false
+ }
+ }
+ true
+ }
+
+
+ val options = listOf("전체공개", "친구공개", "비공개")
+ val adapter = ArrayAdapter(this, R.layout.item_dropdown, options)
+ binding.exposedDropdown.setAdapter(adapter)
+
+
+ binding.exposedDropdown.setOnClickListener {
+ binding.exposedDropdown.showDropDown()
+ }
+
+ binding.btnHashtag.setOnClickListener {
+ showHashtagDialog { newHashtag ->
+ hashtags.add(newHashtag)
+ createHashtagTextView(newHashtag, binding.clHashtag, binding.flowHashtagContainer)
+ }
+ }
+
+ }
+
+ private fun uploadFullPost(frontPhotoPath: String, backPhotoPath: String) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val frontFileName = "${UUID.randomUUID()}.jpg"
+ val backFileName = "${UUID.randomUUID()}.jpg"
+
+ // 1. Presigned URL 요청
+ val presignedRequest = mapOf(
+ "frontImageUrl" to frontFileName,
+ "backImageUrl" to backFileName
+ )
+ val requestBody = JsonUtils.createRequestBody(presignedRequest)
+ val presignedResponse = postService.getPresignedUrls(requestBody)
+
+ val frontPresignedUrl = presignedResponse.result.frontImageUrl
+ val backPresignedUrl = presignedResponse.result.backImageUrl
+
+ // 2. 이미지 PUT
+ val okHttpClient = OkHttpClient()
+
+ fun putImage(filePath: String, url: String): Boolean {
+ val file = File(filePath)
+ val request = Request.Builder()
+ .url(url)
+ .put(file.asRequestBody("image/jpeg".toMediaType()))
+ .build()
+ val response = okHttpClient.newCall(request).execute()
+ return response.isSuccessful
+ }
+
+ val frontUploadSuccess = putImage(frontPhotoPath, frontPresignedUrl)
+ val backUploadSuccess = putImage(backPhotoPath, backPresignedUrl)
+
+ if (!frontUploadSuccess || !backUploadSuccess) {
+ runOnUiThread {
+ Toast.makeText(this@BackOnlyActivity, "이미지 업로드 실패", Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+
+ // 3. 최종 게시글 업로드
+ val frontItemtags = frontTagList?.map {
+ ItemTag(x = it.xRatio, y = it.yRatio, content = it.tagText)
+ } ?: emptyList()
+
+ val backItemtags = backTagList?.map {
+ ItemTag(x = it.xRatio, y = it.yRatio, content = it.tagText)
+ } ?: emptyList()
+
+ val visibility = when (binding.exposedDropdown.text.toString()) {
+ "전체공개" -> "PUBLIC"
+ "친구공개" -> "FRIEND"
+ "비공개" -> "PRIVATE"
+ else -> "PUBLIC"
+ }
+ val finalPost = PostRequest(
+ frontImage = frontPresignedUrl.substringBefore("?"),
+ backImage = backPresignedUrl.substringBefore("?"),
+ hashtags = hashtags,
+ frontItemtags = frontItemtags,
+ backItemtags = backItemtags,
+ pointColor = "#${Integer.toHexString(pointColor)}",
+ visibility = visibility,
+ mission = true
+ )
+
+ viewModel.uploadPost(finalPost)
+
+ } catch (e: Exception) {
+ runOnUiThread {
+ Toast.makeText(this@BackOnlyActivity, "업로드 중 오류: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ Log.e("UPLOAD", "에러: ${e.message}", e)
+ }
+ }
+ }
+
+
+ private fun showHashtagDialog(onHashtagSaved: (String) -> Unit) {
+ // 다이얼로그 생성
+ val dialog = Dialog(this)
+ val binding = CustomTagDialogBinding.inflate(layoutInflater)
+
+ dialog.setContentView(binding.root)
+ dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) // 배경 투명화
+
+ // 기본값으로 '#' 추가
+ binding.etHashtag.setText("#")
+ binding.etHashtag.setSelection(1) // 커서를 # 뒤로 이동
+
+ // 취소 버튼
+ binding.btnCancel.setOnClickListener {
+ dialog.dismiss()
+ }
+
+ // 확인 버튼
+ binding.btnConfirm.setOnClickListener {
+ val input = binding.etHashtag.text.toString().trim()
+
+ // 입력 검증
+ if (input.length > 1 && input.startsWith("#")) {
+ onHashtagSaved(input)
+ dialog.dismiss()
+ } else {
+ Toast.makeText(this, "올바른 해시태그를 입력하세요.", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ dialog.show()
+ }
+ private fun createHashtagTextView(text: String, parentLayout: ConstraintLayout, flow: Flow) {
+
+ val font: Typeface? = ResourcesCompat.getFont(this, R.font.pretendard_regular)
+ val textView = TextView(this).apply {
+ id = View.generateViewId()
+ this.text = text
+ textSize = 16f
+ typeface = ResourcesCompat.getFont(context, R.font.noto_medium)
+ includeFontPadding = false
+ setTextColor(ContextCompat.getColor(context, R.color.white))
+ setBackgroundResource(R.drawable.bg_detail_hashtag)
+ setPadding(36, 12, 36, 12)
+
+ layoutParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+
+ // TODO: 해시태그 클릭 시 삭제 기능 추가
+ // setOnClickListener {}
+ }
+
+ parentLayout.addView(textView)
+ flow.referencedIds += textView.id
+ }
+
+ // 아이콘 색상 변경
+ private fun setIconColor(view: android.view.View, color: Int) {
+ val bg = view.background
+ if (bg is GradientDrawable) {
+ bg.setColor(color)
+ } else {
+ view.setBackgroundColor(color)
+ }
+ }
+
+ // 이미지에서 색상 추출
+ private fun getTouchedColor(bitmap: Bitmap, touchX: Float, touchY: Float): Int {
+ val ivWidth = binding.imageViewBackOnly.width
+ val ivHeight = binding.imageViewBackOnly.height
+
+ val bmpWidth = bitmap.width
+ val bmpHeight = bitmap.height
+
+ val xRatio = touchX / ivWidth
+ val yRatio = touchY / ivHeight
+
+ val pixelX = (xRatio * bmpWidth).toInt().coerceIn(0, bmpWidth - 1)
+ val pixelY = (yRatio * bmpHeight).toInt().coerceIn(0, bmpHeight - 1)
+
+ return bitmap.getPixel(pixelX, pixelY)
+ }
+
+ private fun addTagView(tagText: String, xRatio: Float, yRatio: Float) {
+ val displayTag = getDisplayTag(tagText) // ✅ display tag 사용
+
+ val tagView = android.widget.TextView(this).apply {
+ text = displayTag
+ setTextColor(Color.WHITE)
+ textSize = 14f
+ setBackgroundResource(com.example.umc_closit.R.drawable.bg_hashtag)
+ val leftPad = dpToPx(30)
+ val pad = dpToPx(8)
+ setPadding(leftPad, pad, pad, pad)
+
+ // 전체 태그를 클릭하면 Toast로 보여주기 (선택 사항)
+ setOnClickListener {
+ Toast.makeText(context, "전체 태그: $tagText", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ val layoutParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+
+ val parentWidth = binding.imageAndTag.width.toFloat()
+ val parentHeight = binding.imageAndTag.height.toFloat()
+
+ layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
+ layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
+ layoutParams.leftMargin = (parentWidth * xRatio).toInt()
+ layoutParams.topMargin = (parentHeight * yRatio).toInt()
+
+ binding.imageAndTag.addView(tagView, layoutParams)
+ }
+
+
+ private fun dpToPx(dp: Int): Int {
+ val scale = resources.displayMetrics.density
+ return (dp * scale + 0.5f).toInt()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == BackOnlyActivity.TAGGING_REQUEST_CODE && resultCode == RESULT_OK) {
+ val tagList = data?.getParcelableArrayListExtra("tagList")
+ if (tagList != null) {
+ backTagList = tagList
+ for (tag in tagList) {
+ addTagView(tag.tagText, tag.xRatio, tag.yRatio)
+ }
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/mission/FrontOnlyActivity.kt b/app/src/main/java/com/example/umc_closit/ui/mission/FrontOnlyActivity.kt
new file mode 100644
index 0000000..47dcf70
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/mission/FrontOnlyActivity.kt
@@ -0,0 +1,291 @@
+package com.example.umc_closit.ui.mission
+
+import android.app.Dialog
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.RenderEffect
+import android.graphics.Typeface
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.graphics.Shader
+import android.os.Build
+import androidx.annotation.RequiresApi
+import android.util.Log
+import android.view.MotionEvent
+import android.view.View
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.constraintlayout.helper.widget.Flow
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import com.example.mission.utils.RotateBitmap.rotateBitmapIfNeeded
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.post.TagData
+import com.example.umc_closit.databinding.ActivityFrontOnlyBinding
+import com.example.umc_closit.databinding.CustomTagDialogBinding
+
+
+class FrontOnlyActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityFrontOnlyBinding
+
+ private var frontPhotoPath: String? = null
+ private var backPhotoPath: String? = null
+
+ private val hashtags = mutableListOf()
+
+ private var originalBitmap: Bitmap? = null
+
+ var pointColor: Int? = null
+
+ var ifTagged = false
+
+ // 새 태그 데이터를 저장할 멤버 변수 추가
+ private var receivedTagList: ArrayList? = null
+
+ companion object {
+ private const val TAGGING_REQUEST_CODE = 1001
+ }
+
+ private fun getDisplayTag(fullTag: String): String {
+ return if (fullTag.length > 7) fullTag.substring(0, 7) + "..." else fullTag
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityFrontOnlyBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 툴바 뒤로가기 버튼 설정
+ binding.ivBack.setOnClickListener {
+ onBackPressed()
+ }
+
+ frontPhotoPath = intent.getStringExtra("frontPhotoPath")
+ frontPhotoPath?.let { path ->
+ originalBitmap = rotateBitmapIfNeeded(path)
+ originalBitmap?.let { bmp ->
+ binding.imageViewFrontOnly.setImageBitmap(bmp)
+ }
+ // 배경에 사진 보이기
+ originalBitmap?.let { bmp ->
+ binding.imageViewFrontOnlyBackground.setImageBitmap(bmp)
+ }
+ }
+
+ //배경 사진 blur 효과 추가
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ) {
+ val blurEffect = RenderEffect.createBlurEffect(
+ 40f,40f, Shader.TileMode.CLAMP
+ )
+ binding.blurBox.setRenderEffect(blurEffect)
+ binding.blurBox.alpha = 0.8f
+
+ } else {
+ // API 31보다 아래버전
+ binding.blurBox.setBackgroundColor(0x88FFFFFF.toInt()) // 반투명 흰색
+ }
+
+ backPhotoPath = intent.getStringExtra("backPhotoPath")
+
+ var isColorExtractMode = false
+
+ binding.viewColorIcon.setOnClickListener {
+ isColorExtractMode = !isColorExtractMode
+ }
+
+ binding.addItem.setOnClickListener{
+ val intent = Intent(this, TaggingActivity::class.java).apply {
+ putExtra("photoPath", frontPhotoPath)
+ }
+ startActivityForResult(intent, TAGGING_REQUEST_CODE)
+ }
+
+ binding.imageViewFrontOnly.setOnTouchListener { view, event ->
+ if (isColorExtractMode) {
+ if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_MOVE) {
+ originalBitmap?.let { bmp ->
+ val color = getTouchedColor(bmp, event.x, event.y)
+ setIconColor(binding.viewColorIcon, color)
+ }
+ }
+ }
+ true
+ }
+
+
+ binding.btnHashtag.setOnClickListener {
+ showHashtagDialog { newHashtag ->
+ hashtags.add(newHashtag)
+ createHashtagTextView(newHashtag, binding.clHashtag, binding.flowHashtagContainer)
+ }
+ }
+
+ // BackOnlyActivity로 이동
+ binding.btnContinue.setOnClickListener {
+ val intent = Intent(this, BackOnlyActivity::class.java).apply {
+ putExtra("frontPhotoPath", frontPhotoPath)
+ putExtra("backPhotoPath", backPhotoPath)
+
+ if (ifTagged) {
+ putParcelableArrayListExtra("frontTagList", receivedTagList ?: arrayListOf())
+ }
+ putStringArrayListExtra("hashtags", ArrayList(hashtags))
+
+ // 포인트 색상 전달 (없으면 기본값 -1)
+ putExtra("pointColor", pointColor ?: -1)
+
+ }
+ startActivity(intent)
+ }
+ }
+
+ // 해시태그 입력 다이얼로그
+ private fun showHashtagDialog(onHashtagSaved: (String) -> Unit) {
+ // 다이얼로그 생성
+ val dialog = Dialog(this)
+ val binding = CustomTagDialogBinding.inflate(layoutInflater)
+
+ dialog.setContentView(binding.root)
+ dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) // 배경 투명화
+
+ // 기본값으로 '#' 추가
+ binding.etHashtag.setText("#")
+ binding.etHashtag.setSelection(1) // 커서를 # 뒤로 이동
+
+ // 취소 버튼
+ binding.btnCancel.setOnClickListener {
+ dialog.dismiss()
+ }
+
+ // 확인 버튼
+ binding.btnConfirm.setOnClickListener {
+ val input = binding.etHashtag.text.toString().trim()
+
+ // 입력 검증
+ if (input.length > 1 && input.startsWith("#")) {
+ onHashtagSaved(input)
+ dialog.dismiss()
+ } else {
+ Toast.makeText(this, "올바른 해시태그를 입력하세요.", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ dialog.show()
+ }
+
+ // 아이콘 색상 변경
+ private fun setIconColor(view: android.view.View, color: Int) {
+ val bg = view.background
+ if (bg is GradientDrawable) {
+ bg.setColor(color)
+ pointColor = color
+ } else {
+ view.setBackgroundColor(color)
+ }
+ }
+
+ // 이미지에서 색상 추출
+ private fun getTouchedColor(bitmap: Bitmap, touchX: Float, touchY: Float): Int {
+ val ivWidth = binding.imageViewFrontOnly.width
+ val ivHeight = binding.imageViewFrontOnly.height
+
+ val bmpWidth = bitmap.width
+ val bmpHeight = bitmap.height
+
+ val xRatio = touchX / ivWidth
+ val yRatio = touchY / ivHeight
+
+ val pixelX = (xRatio * bmpWidth).toInt().coerceIn(0, bmpWidth - 1)
+ val pixelY = (yRatio * bmpHeight).toInt().coerceIn(0, bmpHeight - 1)
+
+ return bitmap.getPixel(pixelX, pixelY)
+ }
+
+ private fun addTagView(tagText: String, xRatio: Float, yRatio: Float) {
+ val displayTag = getDisplayTag(tagText) // ✅ display tag 사용
+
+ val tagView = android.widget.TextView(this).apply {
+ text = displayTag
+ setTextColor(Color.WHITE)
+ textSize = 14f
+ setBackgroundResource(com.example.umc_closit.R.drawable.bg_hashtag)
+ val leftPad = dpToPx(30)
+ val pad = dpToPx(8)
+ setPadding(leftPad, pad, pad, pad)
+
+ // 전체 태그를 클릭하면 Toast로 보여주기 (선택 사항)
+ setOnClickListener {
+ Toast.makeText(context, "전체 태그: $tagText", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ val layoutParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+
+ val parentWidth = binding.imageAndTag.width.toFloat()
+ val parentHeight = binding.imageAndTag.height.toFloat()
+
+ layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
+ layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
+ layoutParams.leftMargin = (parentWidth * xRatio).toInt()
+ layoutParams.topMargin = (parentHeight * yRatio).toInt()
+
+ binding.imageAndTag.addView(tagView, layoutParams)
+ }
+
+ private fun dpToPx(dp: Int): Int {
+ val scale = resources.displayMetrics.density
+ return (dp * scale + 0.5f).toInt()
+ }
+
+ private fun createHashtagTextView(text: String, parentLayout: ConstraintLayout, flow: Flow) {
+
+ val font: Typeface? = ResourcesCompat.getFont(this, R.font.pretendard_regular)
+ val textView = TextView(this).apply {
+ id = View.generateViewId()
+ this.text = text
+ textSize = 16f
+ typeface = ResourcesCompat.getFont(context, R.font.noto_medium)
+ includeFontPadding = false
+ setTextColor(ContextCompat.getColor(context, R.color.white))
+ setBackgroundResource(R.drawable.bg_detail_hashtag)
+ setPadding(36, 12, 36, 12)
+
+ layoutParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+
+ // TODO: 해시태그 클릭 시 삭제 기능 추가
+ // setOnClickListener {}
+ }
+
+ parentLayout.addView(textView)
+ flow.referencedIds += textView.id
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == TAGGING_REQUEST_CODE && resultCode == RESULT_OK) {
+
+ val tagList = data?.getParcelableArrayListExtra("tagList")
+ if (tagList != null) {
+ ifTagged = true
+ receivedTagList = tagList
+ for (tag in tagList) {
+ addTagView(tag.tagText, tag.xRatio, tag.yRatio)
+ }
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/mission/MissionActivity.kt b/app/src/main/java/com/example/umc_closit/ui/mission/MissionActivity.kt
new file mode 100644
index 0000000..b04b622
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/mission/MissionActivity.kt
@@ -0,0 +1,53 @@
+package com.example.umc_closit.ui.mission
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import com.example.mission.camera.CameraFrontCallback
+import com.example.mission.camera.CameraPreviewManager
+import com.example.umc_closit.databinding.ActivityMissionBinding
+
+class MissionActivity : AppCompatActivity(), CameraFrontCallback {
+
+ private lateinit var binding: ActivityMissionBinding
+ private lateinit var cameraPreviewManager: CameraPreviewManager
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMissionBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 뒤로 가기 버튼 클릭 리스너 설정
+ binding.ivBack.setOnClickListener {
+ onBackPressed()
+ }
+
+ // CameraPreviewManager 초기화
+ cameraPreviewManager = CameraPreviewManager(
+ context = this,
+ surfaceView = binding.surfaceView,
+ captureButton = binding.btnCapturePhoto,
+ cameraType = CameraPreviewManager.CameraType.FRONT // 전면 카메라만 사용
+ )
+
+ // 콜백 설정 및 초기화
+ cameraPreviewManager.frontCallback = this
+ cameraPreviewManager.initialize()
+ }
+
+ override fun onFrontPhotoCaptured(frontPhotoPath: String) {
+ val intent = Intent(this, PreviewActivity::class.java).apply {
+ putExtra("frontPhotoPath", frontPhotoPath)
+ }
+ startActivity(intent)
+ }
+
+
+ // 권한 요청 응답 처리
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ cameraPreviewManager.onRequestPermissionsResult(requestCode, grantResults)
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/mission/MissionBackActivity.kt b/app/src/main/java/com/example/umc_closit/ui/mission/MissionBackActivity.kt
new file mode 100644
index 0000000..17b51e7
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/mission/MissionBackActivity.kt
@@ -0,0 +1,55 @@
+package com.example.umc_closit.ui.mission
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import com.example.mission.camera.CameraBackCallback
+import com.example.mission.camera.CameraPreviewManager
+import com.example.umc_closit.databinding.ActivityMissionBackBinding
+
+class MissionBackActivity : AppCompatActivity(), CameraBackCallback {
+
+ private lateinit var binding: ActivityMissionBackBinding
+ private lateinit var cameraPreviewManager: CameraPreviewManager
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMissionBackBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // 뒤로 가기 버튼 클릭 리스너 설정
+ binding.ivBack.setOnClickListener {
+ onBackPressed()
+ }
+
+ // CameraPreviewManager 초기화
+ cameraPreviewManager = CameraPreviewManager(
+ context = this,
+ surfaceView = binding.surfaceView,
+ captureButton = binding.btnCapturePhoto,
+ cameraType = CameraPreviewManager.CameraType.BACK // 전면 카메라만 사용
+ )
+
+ // 콜백 설정 및 초기화
+ cameraPreviewManager.backCallback = this
+ cameraPreviewManager.initialize()
+ }
+
+ // 전면 사진이 촬영되었을 때 PreviewActivity로 이동
+ override fun onBothPhotosCaptured(backPhotoPath: String) {
+ val intent = Intent(this, PreviewBackActivity::class.java).apply {
+ putExtra("frontPhotoPath", intent.getStringExtra("frontPhotoPath"))
+ putExtra("backPhotoPath", backPhotoPath)
+ }
+ startActivity(intent)
+ }
+
+ // 권한 요청 응답 처리
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ cameraPreviewManager.onRequestPermissionsResult(requestCode, grantResults)
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/mission/PreviewActivity.kt b/app/src/main/java/com/example/umc_closit/ui/mission/PreviewActivity.kt
new file mode 100644
index 0000000..9e566af
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/mission/PreviewActivity.kt
@@ -0,0 +1,57 @@
+package com.example.umc_closit.ui.mission
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import com.example.mission.utils.RotateBitmap.rotateBitmapIfNeeded
+import com.example.umc_closit.databinding.ActivityPreviewBinding
+
+class PreviewActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityPreviewBinding
+
+ private var mainPhotoPath: String? = null
+ private var smallPhotoPath: String? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityPreviewBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ val frontPhotoPath = intent.getStringExtra("frontPhotoPath")
+
+
+// binding.ivBack.setOnClickListener {
+// onBackPressed()
+// }
+
+ mainPhotoPath = frontPhotoPath
+
+
+ loadImages()
+
+ binding.btnRetake.setOnClickListener {
+ val intent = Intent(this, MissionActivity::class.java)
+ startActivity(intent)
+ finish()
+ }
+
+ binding.btnNext.setOnClickListener {
+ val intent = Intent(this, MissionBackActivity::class.java).apply {
+ putExtra("frontPhotoPath", frontPhotoPath)
+
+ }
+ startActivity(intent)
+ }
+ }
+
+ private fun loadImages() {
+ mainPhotoPath?.let { path ->
+ val bitmap = rotateBitmapIfNeeded(path)
+ binding.imageViewMain.setImageBitmap(bitmap)
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/mission/PreviewBackActivity.kt b/app/src/main/java/com/example/umc_closit/ui/mission/PreviewBackActivity.kt
new file mode 100644
index 0000000..deb6c2b
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/mission/PreviewBackActivity.kt
@@ -0,0 +1,79 @@
+package com.example.umc_closit.ui.mission
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import com.example.mission.utils.RotateBitmap.rotateBitmapIfNeeded
+import com.example.umc_closit.databinding.ActivityPreviewBackBinding
+
+class PreviewBackActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityPreviewBackBinding
+
+ private var mainPhotoPath: String? = null
+ private var smallPhotoPath: String? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityPreviewBackBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val frontPhotoPath = intent.getStringExtra("frontPhotoPath")
+ val backPhotoPath = intent.getStringExtra("backPhotoPath")
+
+
+
+ mainPhotoPath = backPhotoPath
+ //smallPhotoPath = backPhotoPath
+
+ loadImages()
+
+ /*
+ binding.imageViewSmall.setOnClickListener {
+ swapImages()
+ }
+
+ */
+
+ binding.btnRetake.setOnClickListener {
+ val intent = Intent(this, MissionBackActivity::class.java)
+ startActivity(intent)
+ finish()
+ }
+
+ binding.btnNext.setOnClickListener {
+ val intent = Intent(this, FrontOnlyActivity::class.java).apply {
+ putExtra("frontPhotoPath", frontPhotoPath)
+ putExtra("backPhotoPath", backPhotoPath)
+ }
+ startActivity(intent)
+ }
+ }
+
+ private fun loadImages() {
+ mainPhotoPath?.let { path ->
+ val bitmap = rotateBitmapIfNeeded(path)
+ binding.imageViewMain.setImageBitmap(bitmap)
+ }
+ /*
+ smallPhotoPath?.let { path ->
+ val bitmap = rotateBitmapIfNeeded(path)
+ binding.imageViewSmall.setImageBitmap(bitmap)
+ }
+
+ */
+ }
+
+ /*
+ private fun swapImages() {
+ val temp = mainPhotoPath
+ mainPhotoPath = smallPhotoPath
+ smallPhotoPath = temp
+
+ loadImages()
+ }
+
+ */
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/umc_closit/ui/mission/TaggingActivity.kt b/app/src/main/java/com/example/umc_closit/ui/mission/TaggingActivity.kt
new file mode 100644
index 0000000..b5deae4
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/mission/TaggingActivity.kt
@@ -0,0 +1,135 @@
+package com.example.umc_closit.ui.mission
+
+import android.app.Dialog
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import android.util.Log
+import android.view.MotionEvent
+import android.view.View
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.constraintlayout.widget.ConstraintLayout
+import com.example.mission.utils.RotateBitmap.rotateBitmapIfNeeded
+import com.example.umc_closit.data.remote.post.TagData
+import com.example.umc_closit.databinding.ActivityTaggingBinding
+import com.example.umc_closit.databinding.CustomTagDialogBinding
+
+class TaggingActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityTaggingBinding
+ private var originalBitmap: Bitmap? = null
+
+ val tagList = mutableListOf() // 태그 데이터 저장 리스트
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityTaggingBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val photoPath = intent.getStringExtra("photoPath")
+ photoPath?.let { path ->
+ originalBitmap = rotateBitmapIfNeeded(path)
+ originalBitmap?.let { bmp ->
+ binding.imageViewTag.setImageBitmap(bmp)
+ }
+ }
+
+ binding.imageViewTag.setOnTouchListener { _, event ->
+ if (event.action == MotionEvent.ACTION_UP) {
+ val xRatio = event.x / binding.imageViewTag.width.toFloat()
+ val yRatio = event.y / binding.imageViewTag.height.toFloat()
+
+ showTagDialog { fullTag, displayTag ->
+ addTagView(fullTag, displayTag, xRatio, yRatio)
+ }
+ }
+ true
+ }
+
+ binding.ivBack.setOnClickListener {
+ finish()
+ }
+
+ binding.btnSave.setOnClickListener {
+ val tagArrayList = ArrayList(tagList) // Intent로 넘기기 위해 ArrayList로 변환
+ val resultIntent = intent.apply {
+ putParcelableArrayListExtra("tagList", tagArrayList)
+ }
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+
+ tagList.forEach { tag ->
+ val displayTag = if (tag.tagText.length > 7) tag.tagText.substring(0, 7) + "..." else tag.tagText
+ addTagView(tag.tagText, displayTag, tag.xRatio, tag.yRatio)
+ }
+ }
+
+ private fun showTagDialog(onTagSaved: (String, String) -> Unit) {
+ val dialog = Dialog(this)
+ val binding = CustomTagDialogBinding.inflate(layoutInflater)
+
+ dialog.setContentView(binding.root)
+ dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) // 배경 투명 처리
+
+ binding.btnCancel.setOnClickListener {
+ dialog.dismiss()
+ }
+
+ binding.btnConfirm.setOnClickListener {
+ val fullTag = binding.etHashtag.text.toString().trim()
+
+ if (fullTag.isNotEmpty()) {
+ val displayTag = if (fullTag.length > 7) fullTag.substring(0, 7) + "..." else fullTag
+ onTagSaved(fullTag, displayTag) // 원본 태그와 표시 태그 전달
+ dialog.dismiss()
+ }
+ }
+
+ dialog.show()
+ }
+
+ private fun addTagView(fullTag: String, displayTag: String, xRatio: Float, yRatio: Float) {
+ tagList.add(TagData(xRatio, yRatio, fullTag)) // 전체 태그 저장
+
+ val tagView = TextView(this).apply {
+ text = displayTag
+ setTextColor(Color.WHITE)
+ textSize = 14f
+ setBackgroundResource(com.example.umc_closit.R.drawable.bg_hashtag)
+ val leftPad = dpToPx(30)
+ val pad = dpToPx(8)
+ setPadding(leftPad, pad, pad, pad)
+ id = View.generateViewId()
+
+ setOnClickListener {
+ Toast.makeText(context, "전체 태그: $fullTag", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ val layoutParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+
+ // `imageAndTag` 크기를 기준으로 실제 위치 계산
+ val parentWidth = binding.imageAndTag.width.toFloat()
+ val parentHeight = binding.imageAndTag.height.toFloat()
+
+ layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
+ layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
+ layoutParams.leftMargin = (parentWidth * xRatio).toInt()
+ layoutParams.topMargin = (parentHeight * yRatio).toInt()
+
+ binding.imageAndTag.addView(tagView, layoutParams)
+ }
+
+ private fun dpToPx(dp: Int): Int {
+ val scale = resources.displayMetrics.density
+ return (dp * scale + 0.5f).toInt()
+ }
+}
diff --git a/app/src/main/java/com/example/umc_closit/ui/profile/ProfileFragment.kt b/app/src/main/java/com/example/umc_closit/ui/profile/ProfileFragment.kt
new file mode 100644
index 0000000..fb1b333
--- /dev/null
+++ b/app/src/main/java/com/example/umc_closit/ui/profile/ProfileFragment.kt
@@ -0,0 +1,861 @@
+package com.example.umc_closit.ui.profile
+
+import android.app.Dialog
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.graphics.Rect
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.GradientDrawable
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.example.umc_closit.R
+import com.example.umc_closit.data.remote.RetrofitClient
+import com.example.umc_closit.data.remote.profile.BlockRequest
+import com.example.umc_closit.data.remote.profile.FollowRequest
+import com.example.umc_closit.data.remote.profile.FollowResponse
+import com.example.umc_closit.data.remote.profile.UnfollowResponse
+import com.example.umc_closit.databinding.DialogLogoutBinding
+import com.example.umc_closit.databinding.DialogQuitBinding
+import com.example.umc_closit.databinding.FragmentProfileBinding
+import com.example.umc_closit.ui.login.LoginActivity
+import com.example.umc_closit.ui.profile.block.BlockedUserActivity
+import com.example.umc_closit.ui.profile.edit.EditProfileActivity
+import com.example.umc_closit.ui.profile.follow.FollowListActivity
+import com.example.umc_closit.ui.profile.highlight.HighlightAdapter
+import com.example.umc_closit.ui.profile.highlight.HighlightDetailActivity
+import com.example.umc_closit.ui.profile.history.HistoryActivity
+import com.example.umc_closit.ui.profile.posts.SavedPostsActivity
+import com.example.umc_closit.ui.profile.recent.RecentAdapter
+import com.example.umc_closit.ui.profile.recent.RecentDetailActivity
+import com.example.umc_closit.utils.JsonUtils
+import com.example.umc_closit.utils.TokenUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import okio.Timeout
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+
+class ProfileFragment : Fragment() {
+
+ private var _binding: FragmentProfileBinding? = null
+ private val binding get() = _binding!!
+
+ private var loggedInUserClositId: String = ""
+ private var profileUserClositId: String = ""
+ private var isFollowing = false
+
+ private lateinit var highlightAdapter: HighlightAdapter
+ private lateinit var recentAdapter: RecentAdapter
+
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentProfileBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ android.util.Log.d("ProfileFragment", "ProfileFragment onViewCreated 호출됨")
+
+ checkUser()
+ loadUserHighlights()
+ loadRecentPosts()
+
+ binding.tvNoHighlight.visibility = View.GONE
+ binding.tvNoRecent.visibility = View.GONE
+
+ if (isMyProfile()) {
+ binding.tvEditProfileImage.visibility = View.VISIBLE
+ } else {
+ binding.tvEditProfileImage.visibility = View.GONE
+ }
+
+ binding.tvEditProfileImage.setOnClickListener {
+ openGallery()
+ }
+
+ binding.tvFollowingLabel.setOnClickListener {
+ val intent = Intent(requireContext(), FollowListActivity::class.java)
+ if (isMyProfile()) {
+ // 내 프로필일 때 팔로잉 목록으로 이동
+ intent.putExtra("listType", "following") // 팔로잉 목록
+ } else {
+ // 타인의 프로필일 때 타인의 팔로잉 목록으로 이동
+ intent.putExtra("profileUserClositId", profileUserClositId) // 타인의 clositId 전달
+ intent.putExtra("listType", "following") // 팔로잉 목록
+ }
+ startActivity(intent)
+ }
+
+ binding.tvFollowersLabel.setOnClickListener {
+ val intent = Intent(requireContext(), FollowListActivity::class.java)
+ if (isMyProfile()) {
+ // 내 프로필일 때 팔로워 목록으로 이동
+ intent.putExtra("listType", "followers") // 팔로워 목록
+ } else {
+ // 타인의 프로필일 때 타인의 팔로워 목록으로 이동
+ intent.putExtra("profileUserClositId", profileUserClositId) // 타인의 clositId 전달
+ intent.putExtra("listType", "followers") // 팔로워 목록
+ }
+ startActivity(intent)
+ }
+
+ // 유저 정보 불러오기
+ loadUserProfile()
+ checkFollowStatus()
+
+ val screenWidth = resources.displayMetrics.widthPixels
+
+ highlightAdapter = HighlightAdapter(
+ items = mutableListOf(),
+ onAddClick = {
+ startActivity(Intent(requireContext(), HistoryActivity::class.java))
+ },
+ onItemClick = { item ->
+ val postIdList = highlightAdapter.getPostIdList()
+ val clickedPosition = postIdList.indexOf(item.postId)
+
+ val intent = Intent(requireContext(), HighlightDetailActivity::class.java)
+ intent.putIntegerArrayListExtra("postIdList", ArrayList(postIdList))
+ intent.putExtra("clickedPosition", clickedPosition)
+ startActivity(intent)
+ },
+ screenWidth = screenWidth,
+ isMyProfile = isMyProfile()
+ )
+
+ recentAdapter = RecentAdapter(
+ items = emptyList(),
+ screenWidth = resources.displayMetrics.widthPixels
+ ) { postId ->
+ val postIdList = recentAdapter.getPostIdList()
+ val clickedPosition = postIdList.indexOf(postId)
+
+ val intent = Intent(requireContext(), RecentDetailActivity::class.java).apply {
+ putIntegerArrayListExtra("postIdList", ArrayList(postIdList))
+ putExtra("clickedPosition", clickedPosition)
+ }
+ startActivity(intent)
+ }
+
+
+ binding.rvRecent.apply {
+ layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
+ adapter = recentAdapter
+ setHasFixedSize(true)
+ }
+
+
+ binding.rvHighlights.apply {
+ layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
+ adapter = highlightAdapter
+ setHasFixedSize(true)
+ }
+
+ binding.tvHistory.setOnClickListener {
+ startActivity(Intent(requireContext(), HistoryActivity::class.java))
+ }
+
+ binding.tvEditInfo.setOnClickListener {
+ startActivity(Intent(requireContext(), EditProfileActivity::class.java))
+ }
+
+ binding.tvSavePosts.setOnClickListener {
+ startActivity(Intent(requireContext(), SavedPostsActivity::class.java))
+ }
+
+ binding.tvBlock.setOnClickListener {
+ startActivity(Intent(requireContext(), BlockedUserActivity::class.java))
+ }
+
+ binding.tvLogout.setOnClickListener {
+ showLogoutDialog()
+ }
+
+
+ binding.tvQuit.setOnClickListener {
+ val clositId = TokenUtils.getClositId(requireContext()) ?: ""
+ showQuitDialog(clositId) {}
+ }
+
+ binding.tvFollow.setOnClickListener {
+ toggleFollow()
+ }
+
+ binding.btnBlock.setOnClickListener {
+ showBlockDialog(profileUserClositId, binding.tvUsername.text.toString())
+ hideMenuWithAnimation(binding.layoutMenuOptions)
+ }
+
+ binding.ivProfileMenu.setOnClickListener {
+ if (!isMyProfile()) {
+ if (binding.layoutMenuOptions.visibility == View.VISIBLE) {
+ hideMenuWithAnimation(binding.layoutMenuOptions)
+ } else {
+ showMenuWithAnimation(binding.layoutMenuOptions)
+ }
+ }
+ }
+
+ checkBlockStatus(profileUserClositId) { blockedByMe, blockedMe ->
+ if (blockedMe) {
+ showBlockedByOtherUI()
+ } else if (blockedByMe) {
+ showBlockedByMeUI()
+ } else {
+ showNormalUI()
+ }
+ }
+
+ // 루트 뷰 터치 시 메뉴 닫기
+ binding.root.setOnTouchListener { _, event ->
+ if (binding.layoutMenuOptions.visibility == View.VISIBLE) {
+ val menuRect = Rect()
+ binding.layoutMenuOptions.getGlobalVisibleRect(menuRect)
+
+ // 메뉴 영역 밖을 터치했을 때 닫기
+ if (!menuRect.contains(event.rawX.toInt(), event.rawY.toInt())) {
+ hideMenuWithAnimation(binding.layoutMenuOptions)
+ }
+ }
+ false
+ }
+
+ }
+
+ private fun blockUser(targetClositId: String) {
+ val request = BlockRequest(targetClositId)
+ Log.d("BLOCK", "📡 차단 요청 시작 - clositId=$targetClositId")
+
+ val apiCall = {
+ RetrofitClient.profileService.blockUser(request)
+ }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ Log.d("BLOCK", "✅ 응답 도착 - isSuccess=${response.isSuccess}, message=${response.message}")
+
+ if (response.isSuccess) {
+ Toast.makeText(requireContext(), "사용자를 차단했습니다.", Toast.LENGTH_SHORT).show()
+ Log.d("BLOCK", "🚫 차단 성공 - 사용자 뒤로 이동")
+ requireActivity().onBackPressedDispatcher.onBackPressed()
+ } else {
+ Toast.makeText(requireContext(), "차단 실패: ${response.message}", Toast.LENGTH_SHORT).show()
+ Log.w("BLOCK", "❌ 차단 실패: ${response.message}")
+ }
+ },
+ onFailure = { t ->
+ Toast.makeText(requireContext(), "네트워크 오류: ${t.message}", Toast.LENGTH_SHORT).show()
+ Log.e("BLOCK", "🔥 네트워크 오류: ${t.message}", t)
+ },
+ context = requireContext()
+ )
+ }
+
+ private fun updateUI() {
+ // 하이라이트가 비어있으면 tv_no_highlight 보이기
+ if (highlightAdapter.itemCount == 0) {
+ binding.tvNoHighlight.visibility = View.VISIBLE
+ } else {
+ binding.tvNoHighlight.visibility = View.GONE
+ }
+
+ // 최근 게시물이 없으면 tv_no_recent 보이기
+ if (recentAdapter.itemCount == 0) {
+ binding.tvNoRecent.visibility = View.VISIBLE
+ } else {
+ binding.tvNoRecent.visibility = View.GONE
+ }
+ }
+
+ private fun showBlockedByMeUI() {
+ binding.tvFollow.apply {
+ visibility = View.VISIBLE
+ text = "차단됨"
+ setTextColor(resources.getColor(R.color.white, null))
+ (background as GradientDrawable).apply {
+ setColor(resources.getColor(R.color.error_red, null))
+ setStroke(2, resources.getColor(R.color.error_red, null))
+ }
+ }
+ binding.clPosts.visibility = View.GONE
+ binding.tvBlockInfo.visibility = View.VISIBLE
+ binding.ivProfileMenu.visibility = View.VISIBLE
+ }
+
+ private fun showBlockedByOtherUI() {
+ binding.tvFollow.visibility = View.GONE
+ binding.ivProfileMenu.visibility = View.GONE
+ binding.clPosts.visibility = View.GONE
+ binding.tvBlockInfo.visibility = View.VISIBLE
+ }
+
+ private fun showNormalUI() {
+ binding.tvFollow.visibility = View.VISIBLE
+ binding.ivProfileMenu.visibility = View.VISIBLE
+ binding.clPosts.visibility = View.VISIBLE
+ binding.tvBlockInfo.visibility = View.GONE
+ }
+
+
+ private fun loadRecentPosts() {
+ val apiCall = { RetrofitClient.postService.getRecentPosts(profileUserClositId, 0) }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ if (response.isSuccess) {
+ val recentPosts = response.result.userRecentPostDTOList
+ Log.d("RECENT","${recentPosts.size}")
+ recentAdapter.updateItems(recentPosts)
+
+ // 최근 게시글이 없으면 텍스트뷰 표시
+ if (recentPosts.isEmpty()) {
+ binding.tvNoRecent.visibility = View.VISIBLE
+ } else {
+ binding.tvNoRecent.visibility = View.GONE
+ }
+ // UI 업데이트 (하이라이트 및 최근 게시물 확인)
+ updateUI()
+ }
+ },
+ onFailure = { t ->
+ Log.e("ProfileFragment", "최근 게시물 불러오기 실패: ${t.message}")
+ },
+ context = requireContext()
+ )
+ }
+
+
+
+
+ private fun loadUserHighlights() {
+ val apiCall = { RetrofitClient.profileService.getHighlights(profileUserClositId) }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ if (response.isSuccess) {
+ val highlights = response.result.highlights
+ highlightAdapter.setItems(highlights)
+
+ // 하이라이트가 비어있으면 텍스트 보이기
+ if (highlights.isEmpty()) {
+ binding.tvNoHighlight.visibility = View.VISIBLE
+ } else {
+ binding.tvNoHighlight.visibility = View.GONE
+ }
+ updateUI()
+ }
+ },
+ onFailure = { t ->
+ Log.e("ProfileFragment", "하이라이트 불러오기 실패: ${t.message}")
+ },
+ context = requireContext()
+ )
+ }
+
+
+ private fun openGallery() {
+ val intent = Intent(Intent.ACTION_PICK)
+ intent.type = "image/*"
+ startActivityForResult(intent, GALLERY_REQUEST_CODE)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+
+ if (requestCode == GALLERY_REQUEST_CODE && resultCode == AppCompatActivity.RESULT_OK) {
+ val imageUri = data?.data
+ imageUri?.let {
+ uploadProfileImage(it)
+ }
+ }
+ }
+
+ private fun uploadProfileImage(imageUri: Uri) {
+ val clositId = loggedInUserClositId
+ val fileName = "${clositId}_profile_${System.currentTimeMillis()}.jpg"
+
+ // 1. Presigned URL 발급
+ val presignReq = JsonUtils.createRequestBody(mapOf("imageUrl" to fileName))
+ val presignCall = { RetrofitClient.profileService.getPresignedProfileUrl(presignReq) }
+
+ TokenUtils.handleTokenRefresh(
+ call = presignCall(),
+ onSuccess = { response ->
+ val presignedUrl = response.result.imageUrl
+ val pureUrl = presignedUrl.substringBefore("?")
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ // 2. 이미지 byte 변환
+ val inputStream = requireContext().contentResolver.openInputStream(imageUri)
+ val imageBytes = inputStream?.readBytes()
+ inputStream?.close()
+
+ if (imageBytes == null) {
+ Log.e("PROFILE_IMAGE", "이미지 바이트 변환 실패")
+ return@launch
+ }
+
+ // 3. S3 PUT 요청
+ val putRequest = Request.Builder()
+ .url(presignedUrl)
+ .put(imageBytes.toRequestBody("image/jpeg".toMediaType()))
+ .build()
+
+ val putResponse = OkHttpClient().newCall(putRequest).execute()
+ if (!putResponse.isSuccessful) {
+ Log.e("PROFILE_IMAGE", "S3 업로드 실패: ${putResponse.code}")
+ return@launch
+ }
+
+ // 4. 이미지 URL 등록
+ val registerReq = JsonUtils.createRequestBody(
+ mapOf("imageUrl" to pureUrl)
+ )
+ val registerCall = { RetrofitClient.profileService.uploadProfileImage(registerReq) }
+
+ TokenUtils.handleTokenRefresh(
+ call = registerCall(),
+ onSuccess = {
+ requireActivity().runOnUiThread {
+ Toast.makeText(requireContext(), "프로필 이미지 변경 성공", Toast.LENGTH_SHORT).show()
+ loadUserProfile()
+ }
+ },
+ onFailure = {
+ Log.e("PROFILE_IMAGE", "URL 등록 실패: ${it.message}")
+ },
+ context = requireContext()
+ )
+
+ } catch (e: Exception) {
+ Log.e("PROFILE_IMAGE", "예외 발생: ${e.message}", e)
+ }
+ }
+ },
+ onFailure = { t ->
+ Log.e("PROFILE_IMAGE", "Presigned URL 발급 실패: ${t.message}")
+ },
+ context = requireContext()
+ )
+ }
+
+
+ companion object {
+ private const val GALLERY_REQUEST_CODE = 100
+ }
+
+ private fun toggleFollow() {
+ if (isFollowing) {
+ unfollowUser()
+ } else {
+ followUser()
+ }
+ }
+
+ private fun isMyProfile(): Boolean {
+ return loggedInUserClositId.isNotEmpty() && loggedInUserClositId == profileUserClositId
+ }
+
+
+ private fun followUser() {
+ val apiCall = {
+ Log.d("FOLLOW", "팔로우 요청할 ID: $profileUserClositId")
+ Log.d("FOLLOW", "📡 API 요청 준비 - followUser(${profileUserClositId})")
+ RetrofitClient.profileService.followUser(FollowRequest(profileUserClositId))
+ }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response: FollowResponse ->
+ Log.d("FOLLOW", "✅ 응답 도착 - isSuccess=${response.isSuccess}, message=${response.message}")
+
+ if (response.isSuccess) {
+ isFollowing = true
+ updateFollowButtonUI(isFollowing)
+ Log.d("FOLLOW", "🎉 팔로우 성공 - UI 업데이트됨")
+
+ // 팔로워 수 증가
+ val currentFollowers = binding.tvFollowersCount.text.toString().toInt()
+ binding.tvFollowersCount.text = (currentFollowers + 1).toString()
+ Log.d("FOLLOW", "👥 팔로워 수 증가: ${currentFollowers + 1}")
+ } else {
+ Log.w("FOLLOW", "❌ 팔로우 실패: ${response.message}")
+ Toast.makeText(requireContext(), "팔로우 실패: ${response.message}", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onFailure = { t ->
+ Log.e("FOLLOW", "🔥 네트워크 오류: ${t.message}", t)
+ Toast.makeText(requireContext(), "네트워크 오류: ${t.message}", Toast.LENGTH_SHORT).show()
+ },
+ context = requireContext()
+ )
+ }
+
+ private fun unfollowUser() {
+ val apiCall = {
+ RetrofitClient.profileService.unfollowUser(profileUserClositId)
+ }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response: UnfollowResponse ->
+ if (response.isSuccess) {
+ isFollowing = false
+ updateFollowButtonUI(isFollowing)
+ // 팔로워 수 감소
+ val currentFollowers = binding.tvFollowersCount.text.toString().toInt()
+ binding.tvFollowersCount.text = (currentFollowers - 1).toString()
+ } else {
+ Toast.makeText(requireContext(), "언팔로우 실패: ${response.message}", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onFailure = { t ->
+ Toast.makeText(requireContext(), "네트워크 오류: ${t.message}", Toast.LENGTH_SHORT).show()
+ },
+ context = requireContext()
+ )
+ }
+
+ private fun checkFollowStatus() {
+ val apiCall = {
+ RetrofitClient.profileService.checkFollowStatus(profileUserClositId)
+ }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ if (response.isSuccess) {
+ isFollowing = response.result
+ updateFollowButtonUI(isFollowing)
+ }
+ },
+ onFailure = { t ->
+ Log.e("FollowStatus", "팔로우 여부 확인 실패: ${t.message}")
+ },
+ context = requireContext()
+ )
+ }
+
+ private fun loadUserProfile() {
+ if (profileUserClositId.isEmpty()) return
+
+ val apiCall = {
+ RetrofitClient.profileService.getUserProfile(profileUserClositId)
+ }
+
+ TokenUtils.handleTokenRefresh(
+ call = apiCall(),
+ onSuccess = { response ->
+ if (response.isSuccess) {
+ val user = response.result
+
+ // UI 업데이트
+ binding.tvUsername.text = user.name ?: "UNKNOWN"
+
+ Glide.with(requireContext())
+ .load(user.profileImage + "?ts=${System.currentTimeMillis()}")
+ .placeholder(R.drawable.img_profile_user)
+ .error(R.drawable.img_profile_user)
+ .skipMemoryCache(true)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+ .into(binding.ivProfileImage)
+
+
+ // 팔로워, 팔로잉 수 업데이트
+ binding.tvFollowersCount.text = user.followers.toString()
+ binding.tvFollowingCount.text = user.following.toString()
+
+ // 기록일 수 업데이트
+ val dateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())
+ val createdAtDate = dateFormat.parse(user.createdAt.substring(0, 10))
+ val today = Date()
+ val daysBetween = ((today.time - createdAtDate.time) / (1000 * 60 * 60 * 24)).toInt() + 1
+
+ binding.tvRecordDays.text = getString(R.string.record_days_format, daysBetween)
+
+ } else {
+ Toast.makeText(requireContext(), "프로필 정보 로드 실패: ${response.message}", Toast.LENGTH_SHORT).show()
+ }
+ },
+ onFailure = { t ->
+ Toast.makeText(requireContext(), "네트워크 오류: ${t.message}", Toast.LENGTH_SHORT).show()
+ Log.e("PROFILE","네트워크 오류: ${t.message}")
+ },
+ context = requireContext()
+ )
+ }
+
+
+
+ private fun updateFollowButtonUI(following: Boolean) {
+ val backgroundDrawable = binding.tvFollow.background.mutate() as android.graphics.drawable.GradientDrawable
+
+ if (following) {
+ binding.tvFollow.text = "팔로잉"
+ backgroundDrawable.setColor(resources.getColor(R.color.following_gray, null))
+ backgroundDrawable.setStroke(2, resources.getColor(R.color.following_gray, null)) // 테두리 변경
+ binding.tvFollow.setTextColor(resources.getColor(R.color.black, null))
+ } else {
+ binding.tvFollow.text = "팔로우"
+ backgroundDrawable.setColor(resources.getColor(R.color.pink_point, null))
+ backgroundDrawable.setStroke(2, resources.getColor(R.color.pink_point, null)) // 테두리 변경
+ binding.tvFollow.setTextColor(resources.getColor(R.color.white, null))
+ }
+ }
+
+
+ private fun checkUser() {
+ val sp = requireContext().getSharedPreferences("auth_prefs", Context.MODE_PRIVATE)
+ loggedInUserClositId = sp.getString("clositId", "") ?: ""
+ profileUserClositId = arguments?.getString("profileUserClositId", "") ?: ""
+
+ if (loggedInUserClositId == profileUserClositId) {
+ // 내 프로필이면 수정 관련 버튼 보이게
+ binding.clSettingsContainer.visibility = View.VISIBLE
+ binding.tvFollow.visibility = View.GONE
+ } else {
+ // 다른 사람 프로필이면 팔로우 버튼 보이게
+ binding.clSettingsContainer.visibility = View.GONE
+ binding.tvFollow.visibility = View.VISIBLE
+ }
+ }
+
+ private fun showBlockDialog(targetClositId: String, targetUsername: String) {
+ val dialog = Dialog(requireContext())
+ val inflater = LayoutInflater.from(requireContext())
+ val view = inflater.inflate(R.layout.dialog_block, null)
+
+ dialog.setContentView(view)
+ dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ dialog.setCancelable(true)
+
+ val tvBlockId = view.findViewById(R.id.tv_block_id)
+ val tvBlockSpec = view.findViewById(R.id.tv_block_spec)
+ val btnConfirm = view.findViewById