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