diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 83a31c6..b1eff71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.Tiggle"> + android:theme="@style/Theme.Tiggle" + android:usesCleartextTraffic="true"> ) { + prefs.edit { putStringSet(KEY_SET_COOKIES, cookies.toSet()) } + } + + // ▼ 재발급용 Cookie 헤더 조립 (JSESSIONID 등 + refreshToken) + fun buildCookieHeaderForReissue(refreshToken: String): String { + val raw = prefs.getStringSet(KEY_SET_COOKIES, emptySet())!!.toList() + val pairs = raw.map { it.substringBefore(";") } // name=value + return (pairs + "refreshToken=$refreshToken") + .distinctBy { it.substringBefore("=") } + .joinToString("; ") + } + + + // 인증 데이터를 모두 지우는 메소드 + fun clearAuthData() { + prefs.edit { + remove(KEY_ACCESS_TOKEN) + remove(KEY_REFRESH_TOKEN) + remove(KEY_SET_COOKIES) + } + } + + // 로그인 상태 확인 메소드 + fun isLoggedIn(): Boolean { + return getAccessToken() != null + } + + + companion object { + private const val KEY_ACCESS_TOKEN = "access_token" + private const val KEY_REFRESH_TOKEN = "refresh_token" + private const val KEY_SET_COOKIES = "set_cookies" // 쿠키 저장 키 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthApiService.kt new file mode 100644 index 0000000..c9d1c30 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthApiService.kt @@ -0,0 +1,45 @@ +package com.ssafy.tiggle.data.datasource.remote + +import com.ssafy.tiggle.data.model.BaseResponse +import com.ssafy.tiggle.data.model.LoginRequestDto +import com.ssafy.tiggle.data.model.SignUpRequestDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +/** + * 사용자 관련 API 서비스 + */ +interface AuthApiService { + + /** + * 회원가입 + * + * @param signUpRequest 회원가입 요청 데이터 + * @return Response 회원가입 응답 + */ + @POST("auth/join") + suspend fun signUp( + @Body signUpRequest: SignUpRequestDto + ): Response> + + + /** + * 로그인 + * + * @param loginRequest 로그인 요청 데이터 + * @return Response> 로그인 응답 + */ + @POST("auth/login") + suspend fun login( + @Body loginRequest: LoginRequestDto + ): Response> + + @POST("auth/reissue") + suspend fun reissueTokenByCookie( + @Header("Cookie") cookie: String + ): Response> + + +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthInterceptor.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthInterceptor.kt new file mode 100644 index 0000000..1eb8213 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthInterceptor.kt @@ -0,0 +1,97 @@ +package com.ssafy.tiggle.data.datasource.remote + +import android.util.Log +import com.ssafy.tiggle.data.datasource.local.AuthDataSource +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Named + +// 오래 방치 시 첫 요청이 동시에 여러 개 날아가면, 재발급도 동시에 여러 번 발생 +// → 서버가 refreshToken을 회전시키며 둘 중 하나는 INVALID_REFRESH_TOKEN이 됨. +// 이걸 막으려면 재발급은 한 번만 수행하고, 나머지는 결과를 공유해야 한다 +private val refreshMutex = Mutex() + +class AuthInterceptor @Inject constructor( + private val authDataSource: AuthDataSource, + @Named("refresh") private val api: AuthApiService +) : Interceptor { + + private fun stripBearer(raw: String?): String = + raw?.replaceFirst(Regex("^Bearer\\s+", RegexOption.IGNORE_CASE), "")?.trim().orEmpty() + + private fun isAuthPath(url: HttpUrl) = url.encodedPath.startsWith("/auth/") + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + if (isAuthPath(original.url)) return chain.proceed(original) + + // 1) Authorization 부착 (항상 Bearer 1회) + val access = stripBearer(authDataSource.getAccessToken()) + val req = original.newBuilder() + .removeHeader("Authorization") + .apply { + if (access.isNotBlank()) header( + "Authorization", + "Bearer $access" + ) + } + .build() + + var res = chain.proceed(req) + if (res.code != 401) return res + + // 2) 401 → 재발급 (단일 실행) + val refreshed = runBlocking { + refreshMutex.withLock { + val latest = stripBearer(authDataSource.getAccessToken()) + if (latest.isNotBlank() && latest != access) return@withLock true + + val refresh = stripBearer(authDataSource.getRefreshToken()) + if (refresh.isBlank()) return@withLock false + + val cookieHeader = authDataSource.buildCookieHeaderForReissue(refresh) + Log.d("Reissue", "➡️ Cookie header(for reissue)=${cookieHeader}") + + val r = api.reissueTokenByCookie(cookie = cookieHeader) + if (!r.isSuccessful) { + if (r.code() == 401) { + // INVALID_REFRESH_TOKEN 등: 회복 불가 → 세션 정리 + authDataSource.clearAuthData() + } + return@withLock false + } + + val newAccess = stripBearer(r.headers()["Authorization"]) + val setCookies = r.headers().values("Set-Cookie") + authDataSource.saveSetCookies(setCookies) + + val cookieRefresh = setCookies.firstOrNull { it.startsWith("refreshToken=") } + ?.substringAfter("refreshToken=")?.substringBefore(";") + + if (newAccess.isBlank() || cookieRefresh.isNullOrBlank()) return@withLock false + authDataSource.saveTokens(newAccess, cookieRefresh) + true + } + } + + if (!refreshed) { + // 재발급 실패: 기존 401을 그대로 반환 (닫지 말고 반환) + return res + } + + // 재발급 성공: 이제 원 응답 닫고 재시도 + res.close() + val latest = stripBearer(authDataSource.getAccessToken()) + val retry = original.newBuilder() + .removeHeader("Authorization") + .header("Authorization", "Bearer $latest") + .build() + return chain.proceed(retry) + } +} + diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PrettyHttpLoggingInterceptor.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PrettyHttpLoggingInterceptor.kt new file mode 100644 index 0000000..34c84d7 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PrettyHttpLoggingInterceptor.kt @@ -0,0 +1,164 @@ +package com.ssafy.tiggle.data.datasource.remote + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import org.json.JSONArray +import org.json.JSONObject +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit + +class PrettyHttpLoggingInterceptor : Interceptor { + + companion object { + private const val TAG = "OkHttp" + private val UTF8 = Charset.forName("UTF-8") + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestBody = request.body + + var requestBodyString: String? = null + if (requestBody != null) { + val buffer = Buffer() + requestBody.writeTo(buffer) + requestBodyString = buffer.readString(UTF8) + } + + // ┌─────── Request ─────── + Log.i(TAG, "┌─ Request ───────────────────────────────────────────────────────────────────") + Log.i(TAG, "│ ${request.method} ${request.url}") + + if (request.headers.size > 0) { + Log.v(TAG, "│ Headers:") + request.headers.forEach { header -> + Log.v(TAG, "│ ${header.first}: ${header.second}") + } + } + + if (requestBodyString != null) { + + Log.v(TAG, "│ ${getPrettyJson(requestBodyString)}") + } + Log.i(TAG, "└─────────────────────────────────────────────────────────────────────────────") + // └─────────────────────── + + val startNs = System.nanoTime() + val response: Response + try { + response = chain.proceed(request) + } catch (e: Exception) { + Log.e( + TAG, + "┌─ Error ─────────────────────────────────────────────────────────────────────" + ) + Log.e(TAG, "│ HTTP FAILED: $e") + Log.e( + TAG, + "└─────────────────────────────────────────────────────────────────────────────" + ) + throw e + } + val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs) + + val responseBody = response.body + val mediaType: MediaType? = responseBody?.contentType() + val isTextBody = isTextLike(mediaType) + var bodyString: String? = null + + if (isTextBody && responseBody != null) { + try { + // 안전한 방식으로 응답 본문 읽기 + val source = responseBody.source() + source.request(Long.MAX_VALUE) // 전체 본문 요청 + val buffer = source.buffer + + val charset = responseBody.contentType()?.charset(UTF8) ?: UTF8 + bodyString = buffer.clone().readString(charset) + } catch (e: Exception) { + Log.w(TAG, "응답 본문 읽기 실패: ${e.message}") + bodyString = "<응답 본문 읽기 실패: ${e.javaClass.simpleName}>" + } + } + + val logTag = if (response.isSuccessful) Log.INFO else Log.ERROR + + // ┌─────── Response ─────── + Log.println( + logTag, + TAG, + "┌─ Response ──────────────────────────────────────────────────────────────────" + ) + Log.println( + logTag, + TAG, + "│ ${response.code} ${response.message} ${response.request.url} (${tookMs}ms)" + ) + + if (response.headers.size > 0) { + Log.v(TAG, "│ Headers:") + response.headers.forEach { header -> + Log.v(TAG, "│ ${header.first}: ${header.second}") + } + } + + if (!bodyString.isNullOrEmpty()) { + Log.v(TAG, "│ Body:") + Log.v(TAG, "│ ${getPrettyJson(bodyString)}") + } else if (responseBody != null && !isTextBody) { + Log.v( + TAG, + "│ Body: (omitted)" + ) + } + + Log.println( + logTag, + TAG, + "└─────────────────────────────────────────────────────────────────────────────" + ) + // └──────────────────────── + + // 원본 응답 그대로 반환 (buffer.clone()을 사용했으므로 안전) + return response + } + + /** + * 문자열이 JSON 형태이면 예쁘게 포맷팅하고, 아니면 그대로 반환합니다. + */ + private fun getPrettyJson(jsonString: String?): String { + if (jsonString.isNullOrEmpty()) { + return "Empty/Null json content" + } + return try { + val trimmed = jsonString.trim() + if (trimmed.startsWith("{")) { + JSONObject(trimmed).toString(2) + } else if (trimmed.startsWith("[")) { + JSONArray(trimmed).toString(2) + } else { + jsonString + } + } catch (e: Exception) { + jsonString // JSON 파싱 실패 시 원본 문자열 반환 + } + } + + private fun isTextLike(mediaType: MediaType?): Boolean { + if (mediaType == null) return false + val type = mediaType.type + val subtype = mediaType.subtype.lowercase() + if (type == "text") return true + if (type == "application") { + return subtype.contains("json") || + subtype.contains("xml") || + subtype.contains("x-www-form-urlencoded") || + subtype.contains("javascript") + } + return false + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/UniversityApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/UniversityApiService.kt new file mode 100644 index 0000000..e571946 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/UniversityApiService.kt @@ -0,0 +1,33 @@ +package com.ssafy.tiggle.data.datasource.remote + +import com.ssafy.tiggle.data.model.BaseResponse +import com.ssafy.tiggle.data.model.DepartmentDto +import com.ssafy.tiggle.data.model.UniversityDto +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * 대학교/학과 관련 API 서비스 + */ +interface UniversityApiService { + + /** + * 모든 대학교 조회 + * + * @return Response>> 대학교 목록 응답 + */ + @GET("universities") + suspend fun getUniversities(): Response>> + + /** + * 특정 대학교의 학과 목록 조회 + * + * @param universityId 대학교 ID + * @return Response>> 학과 목록 응답 + */ + @GET("universities/{universityId}/departments") + suspend fun getDepartments( + @Path("universityId") universityId: Long + ): Response>> +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/UserApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/UserApiService.kt deleted file mode 100644 index 5f7b571..0000000 --- a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/UserApiService.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.ssafy.tiggle.data.datasource.remote - -import com.ssafy.tiggle.data.model.SignUpRequestDto -import com.ssafy.tiggle.data.model.SignUpResponseDto -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.POST - -/** - * 사용자 관련 API 서비스 - */ -interface UserApiService { - - /** - * 회원가입 - * - * @param signUpRequest 회원가입 요청 데이터 - * @return Response 회원가입 응답 - */ - @POST("auth/signup") - suspend fun signUp( - @Body signUpRequest: SignUpRequestDto - ): Response -} diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/BaseResponse.kt b/app/src/main/java/com/ssafy/tiggle/data/model/BaseResponse.kt index 0fe0a8c..4601567 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/model/BaseResponse.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/model/BaseResponse.kt @@ -2,13 +2,11 @@ package com.ssafy.tiggle.data.model /** * 모든 API 응답의 기본 클래스 - * + * * @param T 응답 데이터의 타입 */ data class BaseResponse( - val success: Boolean, - val message: String, + val result: Boolean, + val message: String? = null, val data: T? = null, - val errorCode: String? = null, - val timestamp: String? = null ) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/DepartmentDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/DepartmentDto.kt new file mode 100644 index 0000000..69c12c7 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/DepartmentDto.kt @@ -0,0 +1,23 @@ +package com.ssafy.tiggle.data.model + +import com.ssafy.tiggle.domain.entity.Department + +/** + * 학과 DTO (Data Transfer Object) + * + * API 응답으로 받는 학과 데이터 구조 + */ +data class DepartmentDto( + val id: Long, + val name: String +) { + /** + * DTO를 도메인 엔티티로 변환 + */ + fun toDomain(): Department { + return Department( + id = id, + name = name + ) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/LoginRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/LoginRequestDto.kt new file mode 100644 index 0000000..19fba05 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/LoginRequestDto.kt @@ -0,0 +1,6 @@ +package com.ssafy.tiggle.data.model + +data class LoginRequestDto( + val email: String, + val password: String +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/SignUpRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/SignUpRequestDto.kt index fb1f258..b0c070d 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/model/SignUpRequestDto.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/model/SignUpRequestDto.kt @@ -5,9 +5,10 @@ package com.ssafy.tiggle.data.model */ data class SignUpRequestDto( val email: String, - val password: String, val name: String, - val school: String, - val department: String, - val studentId: String + val universityId: String, + val departmentId: String, + val studentId: String, + val password: String, + val phone: String ) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/SignUpResponseDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/SignUpResponseDto.kt deleted file mode 100644 index 0b83842..0000000 --- a/app/src/main/java/com/ssafy/tiggle/data/model/SignUpResponseDto.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.ssafy.tiggle.data.model - -/** - * 회원가입 응답 DTO - * BaseResponse의 타입 별칭 - */ -typealias SignUpResponseDto = BaseResponse diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/UniversityDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/UniversityDto.kt new file mode 100644 index 0000000..7998848 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/UniversityDto.kt @@ -0,0 +1,23 @@ +package com.ssafy.tiggle.data.model + +import com.ssafy.tiggle.domain.entity.University + +/** + * 대학교 DTO (Data Transfer Object) + * + * API 응답으로 받는 대학교 데이터 구조 + */ +data class UniversityDto( + val id: Long, + val name: String +) { + /** + * DTO를 도메인 엔티티로 변환 + */ + fun toDomain(): University { + return University( + id = id, + name = name + ) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/UniversityRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/UniversityRepositoryImpl.kt new file mode 100644 index 0000000..6116bd0 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/UniversityRepositoryImpl.kt @@ -0,0 +1,120 @@ +package com.ssafy.tiggle.data.repository + +import android.util.Log +import com.google.gson.Gson +import com.ssafy.tiggle.data.datasource.remote.UniversityApiService +import com.ssafy.tiggle.data.model.BaseResponse +import com.ssafy.tiggle.domain.entity.Department +import com.ssafy.tiggle.domain.entity.University +import com.ssafy.tiggle.domain.repository.UniversityRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * UniversityRepository 구현체 + * + * 실제 데이터 소스(API)와 통신하여 대학교/학과 데이터를 처리합니다. + */ +@Singleton +class UniversityRepositoryImpl @Inject constructor( + private val universityApiService: UniversityApiService +) : UniversityRepository { + + override suspend fun getUniversities(): Result> { + Log.d("UniversityRepositoryImpl", "🏫 대학교 목록 API 호출 시작") + return try { + Log.d("UniversityRepositoryImpl", "📤 대학교 목록 요청 전송 중...") + val response = universityApiService.getUniversities() + Log.d("UniversityRepositoryImpl", "📥 대학교 목록 응답 수신: isSuccessful=${response.isSuccessful}, code=${response.code()}") + + if (response.isSuccessful) { + val body = response.body() + Log.d("UniversityRepositoryImpl", "✅ HTTP 성공 - 응답 본문: $body") + if (body != null && body.result && body.data != null) { + val universities = body.data.map { it.toDomain() } + Log.d("UniversityRepositoryImpl", "🎉 대학교 목록 조회 성공! 총 ${universities.size}개") + Result.success(universities) + } else { + Log.d("UniversityRepositoryImpl", "❌ 서버 로직 실패: ${body?.message}") + Result.failure(Exception(body?.message ?: "대학교 목록을 불러올 수 없습니다.")) + } + } else { + Log.d("UniversityRepositoryImpl", "❌ HTTP 실패: ${response.code()} ${response.message()}") + val errorBody = response.errorBody()?.string() + val message = when (response.code()) { + 400 -> "잘못된 요청입니다." + 401 -> "인증이 필요합니다." + 403 -> "접근 권한이 없습니다." + 404 -> "대학교 목록을 찾을 수 없습니다." + 500 -> "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + 502, 503, 504 -> "서버가 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해주세요." + else -> { + if (!errorBody.isNullOrEmpty()) { + try { + val errorResponse = Gson().fromJson(errorBody, BaseResponse::class.java) + errorResponse.message ?: "대학교 목록을 불러올 수 없습니다. (${response.code()})" + } catch (e: Exception) { + "대학교 목록을 불러올 수 없습니다. (${response.code()})" + } + } else { + "대학교 목록을 불러올 수 없습니다. (${response.code()})" + } + } + } + Result.failure(Exception(message)) + } + } catch (e: Exception) { + Log.e("UniversityRepositoryImpl", "💥 네트워크 예외 발생: ${e.message}", e) + Result.failure(Exception("네트워크 연결을 확인해주세요.")) + } + } + + override suspend fun getDepartments(universityId: Long): Result> { + Log.d("UniversityRepositoryImpl", "🎓 학과 목록 API 호출 시작 (대학교 ID: $universityId)") + return try { + Log.d("UniversityRepositoryImpl", "📤 학과 목록 요청 전송 중...") + val response = universityApiService.getDepartments(universityId) + Log.d("UniversityRepositoryImpl", "📥 학과 목록 응답 수신: isSuccessful=${response.isSuccessful}, code=${response.code()}") + + if (response.isSuccessful) { + val body = response.body() + Log.d("UniversityRepositoryImpl", "✅ HTTP 성공 - 응답 본문: $body") + if (body != null && body.result && body.data != null) { + val departments = body.data.map { it.toDomain() } + Log.d("UniversityRepositoryImpl", "🎉 학과 목록 조회 성공! 총 ${departments.size}개") + Result.success(departments) + } else { + Log.d("UniversityRepositoryImpl", "❌ 서버 로직 실패: ${body?.message}") + Result.failure(Exception(body?.message ?: "학과 목록을 불러올 수 없습니다.")) + } + } else { + Log.d("UniversityRepositoryImpl", "❌ HTTP 실패: ${response.code()} ${response.message()}") + val errorBody = response.errorBody()?.string() + val message = when (response.code()) { + 400 -> "잘못된 요청입니다." + 401 -> "인증이 필요합니다." + 403 -> "접근 권한이 없습니다." + 404 -> "해당 대학교의 학과 목록을 찾을 수 없습니다." + 500 -> "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + 502, 503, 504 -> "서버가 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해주세요." + else -> { + if (!errorBody.isNullOrEmpty()) { + try { + val errorResponse = Gson().fromJson(errorBody, BaseResponse::class.java) + errorResponse.message ?: "학과 목록을 불러올 수 없습니다. (${response.code()})" + } catch (e: Exception) { + "학과 목록을 불러올 수 없습니다. (${response.code()})" + } + } else { + "학과 목록을 불러올 수 없습니다. (${response.code()})" + } + } + } + Result.failure(Exception(message)) + } + } catch (e: Exception) { + Log.e("UniversityRepositoryImpl", "💥 네트워크 예외 발생: ${e.message}", e) + Result.failure(Exception("네트워크 연결을 확인해주세요.")) + } + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/UserRepositoryImpl.kt index 5b80387..53c5e7e 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/UserRepositoryImpl.kt @@ -1,8 +1,12 @@ package com.ssafy.tiggle.data.repository -import com.ssafy.tiggle.data.datasource.remote.UserApiService +import android.util.Log +import com.google.gson.Gson +import com.ssafy.tiggle.data.datasource.local.AuthDataSource +import com.ssafy.tiggle.data.datasource.remote.AuthApiService +import com.ssafy.tiggle.data.model.BaseResponse +import com.ssafy.tiggle.data.model.LoginRequestDto import com.ssafy.tiggle.data.model.SignUpRequestDto -import com.ssafy.tiggle.domain.entity.User import com.ssafy.tiggle.domain.entity.UserSignUp import com.ssafy.tiggle.domain.repository.UserRepository import javax.inject.Inject @@ -10,44 +14,133 @@ import javax.inject.Singleton /** * UserRepository 구현체 - * + * * 실제 데이터 소스(API)와 통신하여 회원가입 데이터를 처리합니다. */ @Singleton class UserRepositoryImpl @Inject constructor( - private val userApiService: UserApiService + private val authApiService: AuthApiService, + private val authDataSource: AuthDataSource ) : UserRepository { - - override suspend fun signUpUser(userSignUp: UserSignUp): Result { + + override suspend fun signUpUser(userSignUp: UserSignUp): Result { return try { // 도메인 엔티티를 DTO로 변환 val signUpRequest = SignUpRequestDto( email = userSignUp.email, password = userSignUp.password, name = userSignUp.name, - school = userSignUp.school, - department = userSignUp.department, - studentId = userSignUp.studentId + universityId = userSignUp.universityId, + departmentId = userSignUp.departmentId, + studentId = userSignUp.studentId, + phone = userSignUp.phone, + ) + + val response = authApiService.signUp(signUpRequest) + + if (response.isSuccessful) { + val body = response.body() + if (body != null && body.result) { + Result.success(Unit) + } else { + Result.failure(Exception(body?.message ?: "알 수 없는 오류가 발생했습니다.")) + } + } else { + val errorBody = response.errorBody()?.string() + val message = when (response.code()) { + 400 -> "잘못된 요청입니다. 입력 정보를 확인해주세요." + 401 -> "인증이 필요합니다." + 403 -> "접근 권한이 없습니다. 회원가입 정보를 확인해주세요." + 404 -> "요청한 페이지를 찾을 수 없습니다." + 409 -> "이미 등록된 정보입니다. 다른 정보로 시도해주세요." + 500 -> "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + 502, 503, 504 -> "서버가 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해주세요." + else -> { + // 응답 본문이 있으면 파싱 시도 + if (!errorBody.isNullOrEmpty()) { + try { + val errorResponse = + Gson().fromJson(errorBody, BaseResponse::class.java) + errorResponse.message ?: "회원가입에 실패했습니다. (${response.code()})" + } catch (e: Exception) { + "회원가입에 실패했습니다. (${response.code()})" + } + } else { + "회원가입에 실패했습니다. (${response.code()})" + } + } + } + Result.failure(Exception(message)) + } + } catch (e: Exception) { + Log.e("UserRepositoryImpl", "💥 네트워크 예외 발생: ${e.message}", e) + Result.failure(Exception("네트워크 연결을 확인해주세요.")) + } + } + + override suspend fun loginUser(email: String, password: String): Result { + return try { + // 도메인 엔티티를 DTO로 변환 + val loginRequest = LoginRequestDto( + email = email, + password = password ) - - val response = userApiService.signUp(signUpRequest) + + val response = authApiService.login(loginRequest) + if (response.isSuccessful) { - val signUpResponse = response.body() - if (signUpResponse?.success == true && signUpResponse.data != null) { - Result.success(signUpResponse.data.toDomain()) + val body = response.body() + if (body != null && body.result) { + val newAccess = stripBearer(response.headers()["Authorization"]) + val setCookies = response.headers().values("Set-Cookie") + authDataSource.saveSetCookies(setCookies) + + val cookieRefresh = setCookies.firstOrNull { it.startsWith("refreshToken=") } + ?.substringAfter("refreshToken=")?.substringBefore(";") + + if (newAccess.isBlank() || cookieRefresh.isNullOrBlank()) { + Result.failure(Exception("인증 토큰을 받을 수 없습니다.")) + } else { + authDataSource.saveTokens(newAccess, cookieRefresh) + Result.success(Unit) + } } else { - Result.failure(Exception(signUpResponse?.message ?: "회원가입에 실패했습니다.")) + Result.failure(Exception(body?.message ?: "로그인에 실패했습니다.")) } } else { - when (response.code()) { - 400 -> Result.failure(Exception("잘못된 요청입니다.")) - 409 -> Result.failure(Exception("이미 등록된 이메일입니다.")) - 500 -> Result.failure(Exception("서버 오류가 발생했습니다.")) - else -> Result.failure(Exception("회원가입에 실패했습니다. (${response.code()})")) + val errorBody = response.errorBody()?.string() + val message = when (response.code()) { + 400 -> "잘못된 요청입니다. 입력 정보를 확인해주세요." + 401 -> "이메일 또는 비밀번호가 올바르지 않습니다." + 403 -> "접근 권한이 없습니다." + 404 -> "존재하지 않는 사용자입니다." + 500 -> "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + 502, 503, 504 -> "서버가 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해주세요." + else -> { + if (!errorBody.isNullOrEmpty()) { + try { + val errorResponse = + Gson().fromJson(errorBody, BaseResponse::class.java) + errorResponse.message ?: "로그인에 실패했습니다. (${response.code()})" + } catch (e: Exception) { + "로그인에 실패했습니다. (${response.code()})" + } + } else { + "로그인에 실패했습니다. (${response.code()})" + } + } } + Result.failure(Exception(message)) } } catch (e: Exception) { - Result.failure(e) + Result.failure(Exception("네트워크 연결을 확인해주세요.")) } } + + /** + * Authorization 헤더에서 Bearer 접두사를 제거하는 유틸리티 함수 + */ + private fun stripBearer(authHeader: String?): String { + return authHeader?.removePrefix("Bearer ")?.trim() ?: "" + } } diff --git a/app/src/main/java/com/ssafy/tiggle/di/AppModule.kt b/app/src/main/java/com/ssafy/tiggle/di/AppModule.kt new file mode 100644 index 0000000..b97ba1f --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/di/AppModule.kt @@ -0,0 +1,21 @@ +package com.ssafy.tiggle.di + +import android.content.Context +import android.content.SharedPreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences { + return context.getSharedPreferences("tiggle_prefs", Context.MODE_PRIVATE) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt b/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt index 942f976..e7ce3d4 100644 --- a/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt +++ b/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt @@ -1,12 +1,13 @@ package com.ssafy.tiggle.di -import com.ssafy.tiggle.data.datasource.remote.UserApiService +import com.ssafy.tiggle.data.datasource.remote.AuthApiService +import com.ssafy.tiggle.data.datasource.remote.PrettyHttpLoggingInterceptor +import com.ssafy.tiggle.data.datasource.remote.UniversityApiService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit @@ -19,23 +20,21 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { - private const val BASE_URL = "https://api.example.com/v1/" + private const val BASE_URL = "http://43.203.36.96/api/" @Provides @Singleton - fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor { - return HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } + fun providePrettyHttpLoggingInterceptor(): PrettyHttpLoggingInterceptor { + return PrettyHttpLoggingInterceptor() } @Provides @Singleton fun provideOkHttpClient( - loggingInterceptor: HttpLoggingInterceptor + prettyLoggingInterceptor: PrettyHttpLoggingInterceptor ): OkHttpClient { return OkHttpClient.Builder() - .addInterceptor(loggingInterceptor) + .addInterceptor(prettyLoggingInterceptor) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) @@ -54,8 +53,14 @@ object NetworkModule { @Provides @Singleton - fun provideUserApiService(retrofit: Retrofit): UserApiService { - return retrofit.create(UserApiService::class.java) + fun provideAuthApiService(retrofit: Retrofit): AuthApiService { + return retrofit.create(AuthApiService::class.java) + } + + @Provides + @Singleton + fun provideUniversityApiService(retrofit: Retrofit): UniversityApiService { + return retrofit.create(UniversityApiService::class.java) } } diff --git a/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt b/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt index ecc13e0..0e9c572 100644 --- a/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt +++ b/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt @@ -1,6 +1,8 @@ package com.ssafy.tiggle.di +import com.ssafy.tiggle.data.repository.UniversityRepositoryImpl import com.ssafy.tiggle.data.repository.UserRepositoryImpl +import com.ssafy.tiggle.domain.repository.UniversityRepository import com.ssafy.tiggle.domain.repository.UserRepository import dagger.Binds import dagger.Module @@ -22,4 +24,10 @@ abstract class RepositoryModule { userRepositoryImpl: UserRepositoryImpl ): UserRepository + @Binds + @Singleton + abstract fun bindUniversityRepository( + universityRepositoryImpl: UniversityRepositoryImpl + ): UniversityRepository + } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/Department.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/Department.kt new file mode 100644 index 0000000..df2175b --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/Department.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.domain.entity + +/** + * 학과 도메인 엔티티 + */ +data class Department( + val id: Long, + val name: String +) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/University.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/University.kt new file mode 100644 index 0000000..bad041d --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/University.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.domain.entity + +/** + * 대학교 도메인 엔티티 + */ +data class University( + val id: Long, + val name: String +) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/UserSignUp.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/UserSignUp.kt index 4d46490..87a2afe 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/UserSignUp.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/UserSignUp.kt @@ -4,7 +4,7 @@ import android.util.Patterns /** * 사용자 회원가입 도메인 엔티티 - * + * * 회원가입 프로세스에서 사용되는 핵심 데이터와 유효성 검사 상태를 포함합니다. */ data class UserSignUp( @@ -12,10 +12,11 @@ data class UserSignUp( val password: String = "", val confirmPassword: String = "", val name: String = "", - val school: String = "", - val department: String = "", + val universityId: String = "", + val departmentId: String = "", val studentId: String = "", - + val phone: String = "", + // 유효성 검사 상태 val emailError: String? = null, val passwordError: String? = null, @@ -23,58 +24,63 @@ data class UserSignUp( val nameError: String? = null, val schoolError: String? = null, val departmentError: String? = null, - val studentIdError: String? = null + val studentIdError: String? = null, + val phoneError: String? = null + ) { /** * 모든 필수 필드가 유효한지 확인 */ fun isValid(): Boolean { - return email.isNotBlank() && - password.isNotBlank() && - confirmPassword.isNotBlank() && - name.isNotBlank() && - school.isNotBlank() && - department.isNotBlank() && - studentId.isNotBlank() && - emailError == null && - passwordError == null && - confirmPasswordError == null && - nameError == null && - schoolError == null && - departmentError == null && - studentIdError == null + return email.isNotBlank() && + password.isNotBlank() && + confirmPassword.isNotBlank() && + name.isNotBlank() && + universityId.isNotBlank() && + departmentId.isNotBlank() && + studentId.isNotBlank() && + emailError == null && + passwordError == null && + confirmPasswordError == null && + nameError == null && + schoolError == null && + departmentError == null && + studentIdError == null && + phoneError == null } - + /** * 필수 필드가 모두 입력되었는지 확인 */ fun hasAllRequiredFields(): Boolean { - return email.isNotBlank() && - password.isNotBlank() && - confirmPassword.isNotBlank() && - name.isNotBlank() && - school.isNotBlank() && - department.isNotBlank() && - studentId.isNotBlank() + return email.isNotBlank() && + password.isNotBlank() && + confirmPassword.isNotBlank() && + name.isNotBlank() && + universityId.isNotBlank() && + departmentId.isNotBlank() && + studentId.isNotBlank() && + phone.isNotBlank() } - + /** * 에러가 있는 필드가 있는지 확인 */ fun hasErrors(): Boolean { - return emailError != null || - passwordError != null || - confirmPasswordError != null || - nameError != null || - schoolError != null || - departmentError != null || - studentIdError != null + return emailError != null || + passwordError != null || + confirmPasswordError != null || + nameError != null || + schoolError != null || + departmentError != null || + studentIdError != null || + phoneError != null } - + // =========================================== // 유효성 검사 비즈니스 로직 // =========================================== - + /** * 이메일 유효성 검사 */ @@ -85,7 +91,7 @@ data class UserSignUp( else -> null } } - + /** * 비밀번호 유효성 검사 */ @@ -99,7 +105,7 @@ data class UserSignUp( else -> null } } - + /** * 비밀번호 확인 유효성 검사 */ @@ -110,7 +116,7 @@ data class UserSignUp( else -> null } } - + /** * 이름 유효성 검사 */ @@ -121,27 +127,27 @@ data class UserSignUp( else -> null } } - + /** * 학교 유효성 검사 */ fun validateSchool(): String? { return when { - school.isBlank() -> "학교를 선택해주세요." + universityId.isBlank() -> "학교를 선택해주세요." else -> null } } - + /** * 학과 유효성 검사 */ fun validateDepartment(): String? { return when { - department.isBlank() -> "학과를 선택해주세요." + departmentId.isBlank() -> "학과를 선택해주세요." else -> null } } - + /** * 학번 유효성 검사 */ @@ -153,7 +159,18 @@ data class UserSignUp( else -> null } } - + + /** + * 전화번호 유효성 검사 (예시로 추가, 필요에 따라 구현) + */ + fun validatePhone(): String? { + return when { + phone.isBlank() -> "전화번호를 입력해주세요." + !Patterns.PHONE.matcher(phone).matches() -> "올바른 전화번호 형식을 입력해주세요." + else -> null + } + } + /** * 전체 유효성 검사를 수행하고 에러가 포함된 새로운 인스턴스 반환 */ @@ -165,10 +182,11 @@ data class UserSignUp( nameError = validateName(), schoolError = validateSchool(), departmentError = validateDepartment(), - studentIdError = validateStudentId() + studentIdError = validateStudentId(), + phoneError = validatePhone() ) } - + /** * 특정 필드만 유효성 검사를 수행하고 업데이트된 인스턴스 반환 */ @@ -181,6 +199,7 @@ data class UserSignUp( ValidationField.SCHOOL -> copy(schoolError = validateSchool()) ValidationField.DEPARTMENT -> copy(departmentError = validateDepartment()) ValidationField.STUDENT_ID -> copy(studentIdError = validateStudentId()) + ValidationField.PHONE -> copy(phoneError = validatePhone()) // 전화번호 유효성 검사는 별도로 구현 필요 } } } @@ -195,5 +214,6 @@ enum class ValidationField { NAME, SCHOOL, DEPARTMENT, - STUDENT_ID + STUDENT_ID, + PHONE } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/UniversityRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/UniversityRepository.kt new file mode 100644 index 0000000..b325816 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/UniversityRepository.kt @@ -0,0 +1,27 @@ +package com.ssafy.tiggle.domain.repository + +import com.ssafy.tiggle.domain.entity.Department +import com.ssafy.tiggle.domain.entity.University + +/** + * 대학교/학과 관련 Repository 인터페이스 + * + * 도메인 레이어에서 정의하는 대학교/학과 데이터 접근 계약 + */ +interface UniversityRepository { + + /** + * 모든 대학교 조회 + * + * @return Result> 성공 시 대학교 목록, 실패 시 에러 + */ + suspend fun getUniversities(): Result> + + /** + * 특정 대학교의 학과 목록 조회 + * + * @param universityId 대학교 ID + * @return Result> 성공 시 학과 목록, 실패 시 에러 + */ + suspend fun getDepartments(universityId: Long): Result> +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/UserRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/UserRepository.kt index 0238b36..c1ea14e 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/UserRepository.kt @@ -4,17 +4,26 @@ import com.ssafy.tiggle.domain.entity.User import com.ssafy.tiggle.domain.entity.UserSignUp /** - * 회원가입 관련 Repository 인터페이스 - * - * 도메인 레이어에서 정의하는 회원가입 데이터 접근 계약 + * 사용자 관련 Repository 인터페이스 + * + * 도메인 레이어에서 정의하는 사용자 데이터 접근 계약 */ interface UserRepository { - + /** * 회원가입 - * + * * @param userSignUp 회원가입 데이터 - * @return Result 성공 시 생성된 사용자 정보, 실패 시 에러 + * @return Result 성공 시 Unit, 실패 시 에러 + */ + suspend fun signUpUser(userSignUp: UserSignUp): Result + + /** + * 로그인 + * + * @param email 이메일 + * @param password 비밀번호 + * @return Result 성공 시 Unit, 실패 시 에러 */ - suspend fun signUpUser(userSignUp: UserSignUp): Result + suspend fun loginUser(email: String, password: String): Result } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetDepartmentsUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetDepartmentsUseCase.kt new file mode 100644 index 0000000..f6d716e --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetDepartmentsUseCase.kt @@ -0,0 +1,24 @@ +package com.ssafy.tiggle.domain.usecase + +import com.ssafy.tiggle.domain.entity.Department +import com.ssafy.tiggle.domain.repository.UniversityRepository +import javax.inject.Inject + +/** + * 학과 목록 조회 UseCase + * + * 학과 목록 조회 비즈니스 로직을 처리합니다. + */ +class GetDepartmentsUseCase @Inject constructor( + private val universityRepository: UniversityRepository +) { + /** + * 학과 목록 조회 실행 + * + * @param universityId 대학교 ID + * @return Result> 성공 시 학과 목록, 실패 시 에러 + */ + suspend operator fun invoke(universityId: Long): Result> { + return universityRepository.getDepartments(universityId) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetUniversitiesUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetUniversitiesUseCase.kt new file mode 100644 index 0000000..b947812 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetUniversitiesUseCase.kt @@ -0,0 +1,23 @@ +package com.ssafy.tiggle.domain.usecase + +import com.ssafy.tiggle.domain.entity.University +import com.ssafy.tiggle.domain.repository.UniversityRepository +import javax.inject.Inject + +/** + * 대학교 목록 조회 UseCase + * + * 대학교 목록 조회 비즈니스 로직을 처리합니다. + */ +class GetUniversitiesUseCase @Inject constructor( + private val universityRepository: UniversityRepository +) { + /** + * 대학교 목록 조회 실행 + * + * @return Result> 성공 시 대학교 목록, 실패 시 에러 + */ + suspend operator fun invoke(): Result> { + return universityRepository.getUniversities() + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/LoginUserUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/LoginUserUseCase.kt new file mode 100644 index 0000000..2785de2 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/LoginUserUseCase.kt @@ -0,0 +1,32 @@ +package com.ssafy.tiggle.domain.usecase + +import com.ssafy.tiggle.domain.repository.UserRepository +import javax.inject.Inject + +/** + * 로그인 UseCase + * + * 로그인 비즈니스 로직을 처리합니다. + */ +class LoginUserUseCase @Inject constructor( + private val userRepository: UserRepository +) { + /** + * 로그인 실행 + * + * @param email 이메일 + * @param password 비밀번호 + * @return Result 성공 시 Unit, 실패 시 에러 + */ + suspend operator fun invoke(email: String, password: String): Result { + // 입력값 검증 + if (email.isBlank() || password.isBlank()) { + return Result.failure( + IllegalArgumentException("이메일과 비밀번호를 입력해주세요.") + ) + } + + // 로그인 실행 + return userRepository.loginUser(email, password) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/SignUpUserUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/SignUpUserUseCase.kt index 2ac7db4..5d55f03 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/usecase/SignUpUserUseCase.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/SignUpUserUseCase.kt @@ -1,6 +1,5 @@ package com.ssafy.tiggle.domain.usecase -import com.ssafy.tiggle.domain.entity.User import com.ssafy.tiggle.domain.entity.UserSignUp import com.ssafy.tiggle.domain.repository.UserRepository import javax.inject.Inject @@ -19,21 +18,16 @@ class SignUpUserUseCase @Inject constructor( * @param userSignUp 회원가입 데이터 * @return Result 성공 시 생성된 사용자 정보, 실패 시 에러 */ - suspend operator fun invoke(userSignUp: UserSignUp): Result { - return try { - // 1. 유효성 검사 - val validatedData = userSignUp.withValidation() - if (!validatedData.isValid()) { - return Result.failure( - IllegalArgumentException("입력 데이터가 유효하지 않습니다.") - ) - } - - // 2. 회원가입 실행 - userRepository.signUpUser(validatedData) - - } catch (e: Exception) { - Result.failure(e) + suspend operator fun invoke(userSignUp: UserSignUp): Result { + // 1. 유효성 검사 + val validatedData = userSignUp.withValidation() + if (!validatedData.isValid()) { + return Result.failure( + IllegalArgumentException("입력 데이터가 유효하지 않습니다.") + ) } + + // 2. 회원가입 실행 후 결과 반환 + return userRepository.signUpUser(validatedData) } } diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt index d619e25..c80b24d 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt @@ -28,7 +28,7 @@ fun NavigationGraph() { Scaffold( bottomBar = { - if (navBackStack.last() != Screen.Login) + if (navBackStack.last() is BottomScreen) BottomNavigation(navBackStack) } ) { innerPadding -> diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginScreen.kt index 6c23a34..302a9c1 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginScreen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginScreen.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.ssafy.tiggle.presentation.ui.components.TiggleButton import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout import com.ssafy.tiggle.presentation.ui.components.TiggleTextField @@ -36,7 +36,7 @@ import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText @Composable fun LoginScreen( modifier: Modifier = Modifier, - viewModel: LoginViewModel = viewModel(), + viewModel: LoginViewModel = hiltViewModel(), onLoginSuccess: () -> Unit = {}, onSignUpClick: () -> Unit = {} ) { @@ -150,50 +150,28 @@ private fun LoginScreenPreview() { ) } -@SuppressLint("ViewModelConstructorInComposable") @Preview(showBackground = true) @Composable private fun LoginScreenWithDataPreview() { - val viewModel = LoginViewModel().apply { - updateEmail("user@example.com") - updatePassword("password123") - } - LoginScreen( - viewModel = viewModel, onLoginSuccess = {}, onSignUpClick = {} ) } -@SuppressLint("ViewModelConstructorInComposable") @Preview(showBackground = true) @Composable private fun LoginScreenErrorPreview() { - val viewModel = LoginViewModel().apply { - updateEmail("invalid-email") - updatePassword("123") // 너무 짧은 비밀번호 - } - LoginScreen( - viewModel = viewModel, onLoginSuccess = {}, onSignUpClick = {} ) } -@SuppressLint("ViewModelConstructorInComposable") @Preview(showBackground = true) @Composable private fun LoginScreenLoadingPreview() { - // 로딩 상태는 실제 앱에서 확인하고, Preview에서는 기본 상태로 표시 - val viewModel = LoginViewModel().apply { - updateEmail("user@example.com") - updatePassword("password123") - } - LoginScreen( - viewModel = viewModel, onLoginSuccess = {}, onSignUpClick = {} ) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginViewModel.kt index c3b2731..36cc427 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginViewModel.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/login/LoginViewModel.kt @@ -1,14 +1,23 @@ package com.ssafy.tiggle.presentation.ui.auth.login +import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ssafy.tiggle.domain.usecase.LoginUserUseCase +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject /** * 로그인 화면의 ViewModel */ -class LoginViewModel : ViewModel() { +@HiltViewModel +class LoginViewModel @Inject constructor( + private val loginUserUseCase: LoginUserUseCase +) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -63,12 +72,29 @@ class LoginViewModel : ViewModel() { generalError = null ) - // TODO: 실제 로그인 로직 구현 - // 임시로 로그인 성공 처리 (실제로는 API 응답에 따라 처리) - _uiState.value = currentState.copy( - isLoading = false, - isLoginSuccess = true - ) + // 실제 로그인 API 호출 + viewModelScope.launch { + Log.d("LoginViewModel", "🎯 로그인 UseCase 호출 시작") + loginUserUseCase(currentState.email, currentState.password) + .onSuccess { + // 로그인 성공 + Log.d("LoginViewModel", "🎉 로그인 성공!") + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoginSuccess = true, + generalError = null + ) + } + .onFailure { exception -> + // 로그인 실패 + Log.e("LoginViewModel", "❌ 로그인 실패: ${exception.message}") + _uiState.value = _uiState.value.copy( + isLoading = false, + generalError = exception.message ?: "로그인에 실패했습니다.", + isLoginSuccess = false + ) + } + } } /** diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpScreen.kt index 33591dc..3866303 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpScreen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpScreen.kt @@ -1,5 +1,6 @@ package com.ssafy.tiggle.presentation.ui.auth.signup +import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -7,11 +8,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview @@ -38,6 +41,20 @@ fun SignUpScreen( viewModel: SignUpViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + + // 회원가입 실패 시 Toast 표시 및 로그인 화면으로 돌아가기 + LaunchedEffect(uiState.errorMessage) { + uiState.errorMessage?.let { error -> + if (uiState.currentStep == SignUpStep.SCHOOL && !uiState.isLoading) { + // 회원가입 API 호출 실패 시에만 Toast 표시 + Toast.makeText(context, "회원가입 실패: $error", Toast.LENGTH_LONG).show() + // 잠시 후 로그인 화면으로 돌아가기 + kotlinx.coroutines.delay(2000) + onBackClick() + } + } + } when (uiState.currentStep) { SignUpStep.TERMS -> { @@ -77,6 +94,15 @@ fun SignUpScreen( ) } + SignUpStep.PHONE -> { + PhoneInputScreen( + uiState = uiState, + onBackClick = { viewModel.goToPreviousStep() }, + onPhoneChange = viewModel::updatePhone, + onNextClick = { viewModel.goToNextStep() } + ) + } + SignUpStep.SCHOOL -> { SchoolInformationScreen( uiState = uiState, @@ -84,16 +110,19 @@ fun SignUpScreen( onSchoolChange = viewModel::updateSchool, onDepartmentChange = viewModel::updateDepartment, onStudentIdChange = viewModel::updateStudentId, + onLoadUniversities = viewModel::loadUniversities, onNextClick = { viewModel.completeSignUp() - viewModel.goToNextStep() } ) } SignUpStep.COMPLETE -> { SignUpCompleteScreen( - onComplete = onSignUpComplete + onComplete = { + viewModel.resetSignUpState() + onSignUpComplete() + } ) } } @@ -337,6 +366,50 @@ private fun NameInputScreen( } } +/** + * 5단계: 전화번호 입력 화면 + */ +@Composable +private fun PhoneInputScreen( + uiState: SignUpUiState, + onBackClick: () -> Unit, + onPhoneChange: (String) -> Unit, + onNextClick: () -> Unit +) { + TiggleScreenLayout( + showBackButton = true, + onBackClick = onBackClick, + bottomButton = { + TiggleButton( + text = "다음", + onClick = onNextClick, + enabled = uiState.userData.phone.isNotBlank() + ) + } + ) { + Column { + Text( + text = "전화번호를\n입력해주세요.", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(32.dp)) + + TiggleTextField( + value = uiState.userData.phone, + onValueChange = onPhoneChange, + label = "전화번호", + placeholder = "예: 010-1234-5678", + keyboardType = KeyboardType.Phone, + isError = uiState.userData.phoneError != null, + errorMessage = uiState.userData.phoneError + ) + } + } +} + /** * 5단계: 학교 정보 입력 화면 (학교/학과/학번 통합) */ @@ -347,29 +420,21 @@ private fun SchoolInformationScreen( onSchoolChange: (String) -> Unit, onDepartmentChange: (String) -> Unit, onStudentIdChange: (String) -> Unit, + onLoadUniversities: () -> Unit, onNextClick: () -> Unit ) { - val schools = listOf( - "서울대학교", - "연세대학교", - "고려대학교", - "SSAFY", - "기타" - ) + // 화면 진입 시 대학교 목록 로드 + LaunchedEffect(Unit) { + onLoadUniversities() + } - val departments = listOf( - "컴퓨터공학과", - "소프트웨어학과", - "전자공학과", - "정보통신공학과", - "경영학과", - "경제학과", - "기계공학과", - "화학공학과", - "건축학과", - "산업공학과", - "기타" - ) + // 드롭다운에 표시할 학교/학과 이름 목록 + val schoolNames = uiState.universities.map { it.name } + val departmentNames = uiState.departments.map { it.name } + + // 현재 선택된 학교/학과 이름 + val selectedSchoolName = uiState.universities.find { "${it.id}" == uiState.userData.universityId }?.name ?: "" + val selectedDepartmentName = uiState.departments.find { "${it.id}" == uiState.userData.departmentId }?.name ?: "" TiggleScreenLayout( showBackButton = true, @@ -379,8 +444,8 @@ private fun SchoolInformationScreen( text = if (uiState.isLoading) "가입 중..." else "회원가입 완료하기", onClick = onNextClick, enabled = !uiState.isLoading && - uiState.userData.school.isNotBlank() && - uiState.userData.department.isNotBlank() && + uiState.userData.universityId.isNotBlank() && + uiState.userData.departmentId.isNotBlank() && uiState.userData.studentId.isNotBlank() && uiState.userData.schoolError == null && uiState.userData.departmentError == null && @@ -410,9 +475,15 @@ private fun SchoolInformationScreen( // 학교 선택 TiggleDropdown( label = "학교", - selectedValue = uiState.userData.school, - options = schools, - onValueChange = onSchoolChange, + selectedValue = selectedSchoolName, + options = schoolNames, + onValueChange = { schoolName -> + // 선택된 학교 이름으로 ID를 찾아서 전달 + val selectedUniversity = uiState.universities.find { it.name == schoolName } + selectedUniversity?.let { university -> + onSchoolChange("${university.id}") + } + }, placeholder = "학교를 선택해주세요" ) @@ -431,9 +502,15 @@ private fun SchoolInformationScreen( // 학과 선택 TiggleDropdown( label = "학과", - selectedValue = uiState.userData.department, - options = departments, - onValueChange = onDepartmentChange, + selectedValue = selectedDepartmentName, + options = departmentNames, + onValueChange = { departmentName -> + // 선택된 학과 이름으로 ID를 찾아서 전달 + val selectedDepartment = uiState.departments.find { it.name == departmentName } + selectedDepartment?.let { department -> + onDepartmentChange("${department.id}") + } + }, placeholder = "학과를 선택해주세요" ) @@ -614,13 +691,39 @@ private fun SignUpNameScreenPreview() { ) } +@Preview(showBackground = true) +@Composable +private fun SignUpPhoneScreenPreview() { + val uiState = SignUpUiState( + currentStep = SignUpStep.PHONE, + userData = UserSignUp( + phone = "01012345678" + ) + ) + + PhoneInputScreen( + uiState = uiState, + onBackClick = {}, + onPhoneChange = {}, + onNextClick = {} + ) +} + @Preview(showBackground = true) @Composable private fun SignUpSchoolScreenPreview() { val uiState = SignUpUiState( currentStep = SignUpStep.SCHOOL, userData = UserSignUp( - school = "SSAFY" + universityId = "1" + ), + universities = listOf( + com.ssafy.tiggle.domain.entity.University(1, "SSAFY"), + com.ssafy.tiggle.domain.entity.University(2, "서울대학교") + ), + departments = listOf( + com.ssafy.tiggle.domain.entity.Department(1, "컴퓨터공학과"), + com.ssafy.tiggle.domain.entity.Department(2, "소프트웨어학과") ) ) @@ -628,10 +731,10 @@ private fun SignUpSchoolScreenPreview() { uiState = uiState, onBackClick = {}, onSchoolChange = {}, - - onNextClick = {}, onDepartmentChange = {}, - onStudentIdChange = { } + onStudentIdChange = {}, + onLoadUniversities = {}, + onNextClick = {} ) } @@ -650,18 +753,23 @@ private fun SignUpLoadingScreenPreview() { currentStep = SignUpStep.SCHOOL, isLoading = true, userData = UserSignUp( - school = "SSAFY" - ) + universityId = "1" + ), + universities = listOf( + com.ssafy.tiggle.domain.entity.University(1, "SSAFY"), + com.ssafy.tiggle.domain.entity.University(2, "서울대학교") + ), + isUniversitiesLoading = true ) SchoolInformationScreen( uiState = uiState, onBackClick = {}, onSchoolChange = {}, - - onNextClick = {}, onDepartmentChange = {}, - onStudentIdChange = { } + onStudentIdChange = {}, + onLoadUniversities = {}, + onNextClick = {} ) } diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpStep.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpStep.kt index fc5bbfd..ec95bc2 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpStep.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpStep.kt @@ -8,6 +8,7 @@ enum class SignUpStep { EMAIL, // 이메일 입력 PASSWORD, // 비밀번호 입력 NAME, // 이름 입력 + PHONE, // 전화번호 입력 SCHOOL, // 학교/학과/학번 입력 COMPLETE // 가입 완료 } \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpUiState.kt index 2a404a7..0954977 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpUiState.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpUiState.kt @@ -1,5 +1,7 @@ package com.ssafy.tiggle.presentation.ui.auth.signup +import com.ssafy.tiggle.domain.entity.Department +import com.ssafy.tiggle.domain.entity.University import com.ssafy.tiggle.domain.entity.UserSignUp /** @@ -14,7 +16,13 @@ data class SignUpUiState( val termsData: TermsData = TermsData(), // 사용자 정보 - val userData: UserSignUp = UserSignUp() + val userData: UserSignUp = UserSignUp(), + + // 대학교/학과 정보 + val universities: List = emptyList(), + val departments: List = emptyList(), + val isUniversitiesLoading: Boolean = false, + val isDepartmentsLoading: Boolean = false ) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpViewModel.kt index 3dbbc05..975572a 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpViewModel.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/auth/signup/SignUpViewModel.kt @@ -1,8 +1,11 @@ package com.ssafy.tiggle.presentation.ui.auth.signup +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ssafy.tiggle.domain.entity.ValidationField +import com.ssafy.tiggle.domain.usecase.GetDepartmentsUseCase +import com.ssafy.tiggle.domain.usecase.GetUniversitiesUseCase import com.ssafy.tiggle.domain.usecase.SignUpUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -16,7 +19,9 @@ import javax.inject.Inject */ @HiltViewModel class SignUpViewModel @Inject constructor( - private val signUpUserUseCase: SignUpUserUseCase + private val signUpUserUseCase: SignUpUserUseCase, + private val getUniversitiesUseCase: GetUniversitiesUseCase, + private val getDepartmentsUseCase: GetDepartmentsUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(SignUpUiState()) @@ -70,21 +75,40 @@ class SignUpViewModel @Inject constructor( _uiState.value = _uiState.value.copy(userData = newData) } + fun updatePhone(phone: String) { + val currentData = _uiState.value.userData + val newData = currentData.copy(phone = phone).validateField(ValidationField.PHONE) + _uiState.value = _uiState.value.copy(userData = newData) + } + fun updateSchool(school: String) { val currentData = _uiState.value.userData - val newData = currentData.copy(school = school).validateField(ValidationField.SCHOOL) + val newData = currentData.copy(universityId = school).validateField(ValidationField.SCHOOL) _uiState.value = _uiState.value.copy(userData = newData) + + // 학교가 변경되면 해당 학교의 학과 목록을 불러옴 + if (school.isNotBlank()) { + loadDepartments(school.toLongOrNull() ?: return) + } else { + // 학교가 선택되지 않으면 학과 목록 초기화 + _uiState.value = _uiState.value.copy( + departments = emptyList(), + userData = _uiState.value.userData.copy(departmentId = "") + ) + } } fun updateDepartment(department: String) { val currentData = _uiState.value.userData - val newData = currentData.copy(department = department).validateField(ValidationField.DEPARTMENT) + val newData = + currentData.copy(departmentId = department).validateField(ValidationField.DEPARTMENT) _uiState.value = _uiState.value.copy(userData = newData) } fun updateStudentId(studentId: String) { val currentData = _uiState.value.userData - val newData = currentData.copy(studentId = studentId).validateField(ValidationField.STUDENT_ID) + val newData = + currentData.copy(studentId = studentId).validateField(ValidationField.STUDENT_ID) _uiState.value = _uiState.value.copy(userData = newData) } @@ -98,7 +122,8 @@ class SignUpViewModel @Inject constructor( SignUpStep.TERMS -> SignUpStep.EMAIL SignUpStep.EMAIL -> SignUpStep.PASSWORD SignUpStep.PASSWORD -> SignUpStep.NAME - SignUpStep.NAME -> SignUpStep.SCHOOL + SignUpStep.NAME -> SignUpStep.PHONE + SignUpStep.PHONE -> SignUpStep.SCHOOL SignUpStep.SCHOOL -> SignUpStep.COMPLETE SignUpStep.COMPLETE -> SignUpStep.COMPLETE } @@ -116,7 +141,8 @@ class SignUpViewModel @Inject constructor( SignUpStep.EMAIL -> SignUpStep.TERMS SignUpStep.PASSWORD -> SignUpStep.EMAIL SignUpStep.NAME -> SignUpStep.PASSWORD - SignUpStep.SCHOOL -> SignUpStep.NAME + SignUpStep.PHONE -> SignUpStep.NAME + SignUpStep.SCHOOL -> SignUpStep.PHONE SignUpStep.COMPLETE -> SignUpStep.SCHOOL } @@ -173,15 +199,27 @@ class SignUpViewModel @Inject constructor( } } + SignUpStep.PHONE -> { + val validatedData = currentState.userData.validateField(ValidationField.PHONE) + + if (validatedData.phoneError != null) { + _uiState.value = currentState.copy(userData = validatedData) + false + } else { + true + } + } + SignUpStep.SCHOOL -> { val validatedData = currentState.userData .validateField(ValidationField.SCHOOL) .validateField(ValidationField.DEPARTMENT) .validateField(ValidationField.STUDENT_ID) - if (validatedData.schoolError != null || - validatedData.departmentError != null || - validatedData.studentIdError != null) { + if (validatedData.schoolError != null || + validatedData.departmentError != null || + validatedData.studentIdError != null + ) { _uiState.value = currentState.copy(userData = validatedData) false } else { @@ -193,8 +231,61 @@ class SignUpViewModel @Inject constructor( } } + // 대학교 목록 불러오기 + fun loadUniversities() { + Log.d("SignUpViewModel", "🏫 대학교 목록 로드 시작") + _uiState.value = _uiState.value.copy(isUniversitiesLoading = true) + + viewModelScope.launch { + getUniversitiesUseCase() + .onSuccess { universities -> + Log.d("SignUpViewModel", "🎉 대학교 목록 로드 성공: ${universities.size}개") + _uiState.value = _uiState.value.copy( + universities = universities, + isUniversitiesLoading = false + ) + } + .onFailure { exception -> + Log.e("SignUpViewModel", "❌ 대학교 목록 로드 실패: ${exception.message}") + _uiState.value = _uiState.value.copy( + isUniversitiesLoading = false, + errorMessage = "대학교 목록을 불러올 수 없습니다." + ) + } + } + } + + // 학과 목록 불러오기 + private fun loadDepartments(universityId: Long) { + Log.d("SignUpViewModel", "🎓 학과 목록 로드 시작 (대학교 ID: $universityId)") + _uiState.value = _uiState.value.copy(isDepartmentsLoading = true) + + viewModelScope.launch { + getDepartmentsUseCase(universityId) + .onSuccess { departments -> + Log.d("SignUpViewModel", "🎉 학과 목록 로드 성공: ${departments.size}개") + _uiState.value = _uiState.value.copy( + departments = departments, + isDepartmentsLoading = false + ) + } + .onFailure { exception -> + Log.e("SignUpViewModel", "❌ 학과 목록 로드 실패: ${exception.message}") + _uiState.value = _uiState.value.copy( + isDepartmentsLoading = false, + errorMessage = "학과 목록을 불러올 수 없습니다." + ) + } + } + } + // 유효성 검사는 도메인 엔티티(UserSignUp)에서 처리합니다. + // 회원가입 상태 초기화 + fun resetSignUpState() { + _uiState.value = SignUpUiState() + } + // 회원가입 완료 fun completeSignUp() { val currentState = _uiState.value @@ -217,9 +308,11 @@ class SignUpViewModel @Inject constructor( // 회원가입 API 호출 viewModelScope.launch { + Log.d("SignUpViewModel", "🎯 UseCase 호출 시작") signUpUserUseCase(validatedData) - .onSuccess { user -> + .onSuccess { // 회원가입 성공 + Log.d("SignUpViewModel", "🎉 UseCase 성공 - COMPLETE 화면으로 이동") _uiState.value = _uiState.value.copy( isLoading = false, currentStep = SignUpStep.COMPLETE, @@ -228,6 +321,7 @@ class SignUpViewModel @Inject constructor( } .onFailure { exception -> // 회원가입 실패 + Log.e("SignUpViewModel", "❌ UseCase 실패: ${exception.message}") _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = exception.message ?: "회원가입에 실패했습니다." diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleCheckboxItem.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleCheckboxItem.kt index d461f8c..b4f1453 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleCheckboxItem.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleCheckboxItem.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -76,7 +75,7 @@ fun TiggleAllAgreeCheckboxItem( modifier = Modifier.size(24.dp) ) { Icon( - imageVector = Icons.Default.KeyboardArrowRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "상세보기", tint = Color.Gray )