diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea4049b..56c1e4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { implementation(libs.retrofit.gson.converter) implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) + implementation(libs.okhttp.urlconnection) // Coroutines implementation(libs.kotlinx.coroutines.android) diff --git a/app/src/main/java/com/ssafy/tiggle/core/fcm/TiggleMessageService.kt b/app/src/main/java/com/ssafy/tiggle/core/fcm/TiggleMessageService.kt index c24dbd0..0ad2d40 100644 --- a/app/src/main/java/com/ssafy/tiggle/core/fcm/TiggleMessageService.kt +++ b/app/src/main/java/com/ssafy/tiggle/core/fcm/TiggleMessageService.kt @@ -83,7 +83,10 @@ class TiggleMessagingService : FirebaseMessagingService() { ?: message.notification?.body ?: "새 알림이 도착했어요." - Log.d(TAG, "onMessageReceived: data=${message.data}, notif=${message.notification}") + val deepLink = message.data["link"] + + Log.d(TAG, "onMessageReceived: data=${message.data}, notif=${message.notification}, " + + "title=$title, body=$body, link=$deepLink") // Android 13+ 권한 체크 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthInterceptor.kt b/app/src/main/java/com/ssafy/tiggle/core/network/AuthInterceptor.kt similarity index 72% rename from app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthInterceptor.kt rename to app/src/main/java/com/ssafy/tiggle/core/network/AuthInterceptor.kt index 1eb8213..9a87517 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/AuthInterceptor.kt +++ b/app/src/main/java/com/ssafy/tiggle/core/network/AuthInterceptor.kt @@ -1,7 +1,7 @@ -package com.ssafy.tiggle.data.datasource.remote +package com.ssafy.tiggle.core.network -import android.util.Log import com.ssafy.tiggle.data.datasource.local.AuthDataSource +import com.ssafy.tiggle.data.datasource.remote.AuthApiService import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -24,7 +24,7 @@ class AuthInterceptor @Inject constructor( 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/") + private fun isAuthPath(url: HttpUrl) = url.encodedPath.startsWith("/api/auth/") override fun intercept(chain: Interceptor.Chain): Response { val original = chain.request() @@ -42,8 +42,8 @@ class AuthInterceptor @Inject constructor( } .build() - var res = chain.proceed(req) - if (res.code != 401) return res + val res = chain.proceed(req) + if (res.code != 401 && res.code != 403) return res // 2) 401 → 재발급 (단일 실행) val refreshed = runBlocking { @@ -51,15 +51,10 @@ class AuthInterceptor @Inject constructor( 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) + // CookieJar가 자동으로 refreshToken/JSESSIONID를 쿠키로 전송함 + val r = api.reissueTokenByCookie() if (!r.isSuccessful) { - if (r.code() == 401) { + if (r.code() == 401 || r.code() == 403) { // INVALID_REFRESH_TOKEN 등: 회복 불가 → 세션 정리 authDataSource.clearAuthData() } @@ -67,14 +62,8 @@ class AuthInterceptor @Inject constructor( } 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) + if (newAccess.isBlank()) return@withLock false + authDataSource.saveAccessToken(newAccess) true } } @@ -94,4 +83,3 @@ class AuthInterceptor @Inject constructor( return chain.proceed(retry) } } - diff --git a/app/src/main/java/com/ssafy/tiggle/core/network/LoggingCookieJar.kt b/app/src/main/java/com/ssafy/tiggle/core/network/LoggingCookieJar.kt new file mode 100644 index 0000000..a6e60f8 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/core/network/LoggingCookieJar.kt @@ -0,0 +1,38 @@ +package com.ssafy.tiggle.core.network + +import android.util.Log +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.JavaNetCookieJar +import java.net.CookieManager +import java.net.CookiePolicy + +class LoggingCookieJar : CookieJar { + + private val javaNetCookieJar = JavaNetCookieJar( + CookieManager().apply { setCookiePolicy(CookiePolicy.ACCEPT_ALL) } + ) + + companion object { + private const val TAG = "CookieJar" + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + Log.d(TAG, "🍪 쿠키 저장 - URL: $url") + cookies.forEach { cookie -> + Log.d(TAG, " 저장: ${cookie.name}=${cookie.value} (secure=${cookie.secure})") + } + + javaNetCookieJar.saveFromResponse(url, cookies) + } + + override fun loadForRequest(url: HttpUrl): List { + val cookies = javaNetCookieJar.loadForRequest(url) + Log.d(TAG, "📤 쿠키 로드 - URL: $url, 개수: ${cookies.size}") + cookies.forEach { cookie -> + Log.d(TAG, " 전송: ${cookie.name}=${cookie.value}") + } + return cookies + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PrettyHttpLoggingInterceptor.kt b/app/src/main/java/com/ssafy/tiggle/core/network/PrettyHttpLoggingInterceptor.kt similarity index 97% rename from app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PrettyHttpLoggingInterceptor.kt rename to app/src/main/java/com/ssafy/tiggle/core/network/PrettyHttpLoggingInterceptor.kt index 34c84d7..c31a174 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PrettyHttpLoggingInterceptor.kt +++ b/app/src/main/java/com/ssafy/tiggle/core/network/PrettyHttpLoggingInterceptor.kt @@ -1,10 +1,9 @@ -package com.ssafy.tiggle.data.datasource.remote +package com.ssafy.tiggle.core.network 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 @@ -41,7 +40,7 @@ class PrettyHttpLoggingInterceptor : Interceptor { } if (requestBodyString != null) { - + Log.v(TAG, "│ ${getPrettyJson(requestBodyString)}") } Log.i(TAG, "└─────────────────────────────────────────────────────────────────────────────") @@ -69,14 +68,14 @@ class PrettyHttpLoggingInterceptor : Interceptor { 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) { @@ -143,7 +142,7 @@ class PrettyHttpLoggingInterceptor : Interceptor { } else { jsonString } - } catch (e: Exception) { + } catch (_: Exception) { jsonString // JSON 파싱 실패 시 원본 문자열 반환 } } diff --git a/app/src/main/java/com/ssafy/tiggle/core/utils/Formatter.kt b/app/src/main/java/com/ssafy/tiggle/core/utils/Formatter.kt new file mode 100644 index 0000000..fbf0c5d --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/core/utils/Formatter.kt @@ -0,0 +1,10 @@ +package com.ssafy.tiggle.core.utils + +import java.util.Locale + +object Formatter { + // 정수 금액 포맷팅: 50,000원 + fun formatCurrency(amount: Long, locale: Locale = Locale.KOREA): String = + String.format(locale, "%,d원", amount) + +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/local/AuthDataSource.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/local/AuthDataSource.kt index 45ad248..a1a029d 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/datasource/local/AuthDataSource.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/local/AuthDataSource.kt @@ -17,14 +17,12 @@ class AuthDataSource @Inject constructor( * - 앱 종료 후에도 유지되는 데이터로, 앱 재시작 시 자동 로그인 기능에 사용 */ - // SharedPreferences에 저장된 인증 토큰을 관리 - fun saveTokens(accessToken: String, refreshToken: String) { + // SharedPreferences에 저장된 access 토큰을 관리 + fun saveAccessToken(accessToken: String) { prefs.edit { putString(KEY_ACCESS_TOKEN, accessToken) - putString(KEY_REFRESH_TOKEN, refreshToken) } Log.d("AuthLocalDataSource", "✅ accessToken 저장됨: $accessToken") - Log.d("AuthLocalDataSource", "✅ refreshToken 저장됨: $refreshToken") } // 토큰을 가져오는 메소드 @@ -32,10 +30,7 @@ class AuthDataSource @Inject constructor( return prefs.getString(KEY_ACCESS_TOKEN, null) } - // 리프레시 토큰을 가져오는 메소드 - fun getRefreshToken(): String? { - return prefs.getString(KEY_REFRESH_TOKEN, null) - } + // refresh 토큰은 쿠키로만 관리됨 /** * 쿠키 관리 클래스 @@ -43,27 +38,13 @@ class AuthDataSource @Inject constructor( * - 재발급 시 필요한 Cookie 헤더 문자열을 구성 */ - // ▼ 모든 Set-Cookie 저장 - fun saveSetCookies(cookies: List) { - 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("; ") - } + // 쿠키는 CookieJar가 관리하므로 앱 로컬 저장 불필요 // 인증 데이터를 모두 지우는 메소드 fun clearAuthData() { prefs.edit { remove(KEY_ACCESS_TOKEN) - remove(KEY_REFRESH_TOKEN) - remove(KEY_SET_COOKIES) } } @@ -75,7 +56,5 @@ class AuthDataSource @Inject constructor( 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 index c9d1c30..3acdb69 100644 --- 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 @@ -5,7 +5,6 @@ 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 /** @@ -37,9 +36,7 @@ interface AuthApiService { ): Response> @POST("auth/reissue") - suspend fun reissueTokenByCookie( - @Header("Cookie") cookie: String - ): Response> + suspend fun reissueTokenByCookie(): Response> } diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DutchPayApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DutchPayApiService.kt new file mode 100644 index 0000000..55bc137 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DutchPayApiService.kt @@ -0,0 +1,15 @@ +package com.ssafy.tiggle.data.datasource.remote + +import com.ssafy.tiggle.data.model.BaseResponse +import com.ssafy.tiggle.data.model.EmptyResponse +import com.ssafy.tiggle.data.model.dutchpay.request.DutchPayRequestDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface DutchPayApiService { + @POST("/api/dutchpay/requests") + suspend fun createDutchPayRequest( + @Body request: DutchPayRequestDto + ): 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 new file mode 100644 index 0000000..ce74042 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/UserApiService.kt @@ -0,0 +1,20 @@ +package com.ssafy.tiggle.data.datasource.remote + +import com.ssafy.tiggle.data.model.BaseResponse +import com.ssafy.tiggle.data.model.UserSummaryDto +import retrofit2.Response +import retrofit2.http.GET + +/** + * 사용자 조회 API 서비스 + */ +interface UserApiService { + + /** + * 전체 사용자 목록 조회 + */ + @GET("users/list") + suspend fun getAllUsers(): Response>> +} + + 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 index 69c12c7..2b52ea2 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/model/DepartmentDto.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/model/DepartmentDto.kt @@ -1,10 +1,10 @@ package com.ssafy.tiggle.data.model -import com.ssafy.tiggle.domain.entity.Department +import com.ssafy.tiggle.domain.entity.auth.Department /** * 학과 DTO (Data Transfer Object) - * + * * API 응답으로 받는 학과 데이터 구조 */ data class DepartmentDto( 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 index 7998848..f3ba932 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/model/UniversityDto.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/model/UniversityDto.kt @@ -1,10 +1,10 @@ package com.ssafy.tiggle.data.model -import com.ssafy.tiggle.domain.entity.University +import com.ssafy.tiggle.domain.entity.auth.University /** * 대학교 DTO (Data Transfer Object) - * + * * API 응답으로 받는 대학교 데이터 구조 */ data class UniversityDto( diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/UserDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/UserDto.kt index 74f82f0..ea83444 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/model/UserDto.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/model/UserDto.kt @@ -1,10 +1,10 @@ package com.ssafy.tiggle.data.model -import com.ssafy.tiggle.domain.entity.User +import com.ssafy.tiggle.domain.entity.auth.User /** * 사용자 DTO (Data Transfer Object) - * + * * API 응답으로 받는 사용자 데이터 구조 */ data class UserDto( diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/UserSummaryDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/UserSummaryDto.kt new file mode 100644 index 0000000..dd65087 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/UserSummaryDto.kt @@ -0,0 +1,18 @@ +package com.ssafy.tiggle.data.model + +import com.ssafy.tiggle.domain.entity.dutchpay.UserSummary + +/** + * 사용자 요약 DTO (리스트용) + */ +data class UserSummaryDto( + val id: Long, + val name: String +) { + fun toDomain(): UserSummary = UserSummary( + id = id, + name = name + ) +} + + diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/dutchpay/request/DutchPayRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/dutchpay/request/DutchPayRequestDto.kt new file mode 100644 index 0000000..a2dbc15 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/dutchpay/request/DutchPayRequestDto.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.data.model.dutchpay.request + +data class DutchPayRequestDto( + val userIds: List, + val totalAmount: Long, + val title: String, + val message: String, + val payMore: Boolean +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..b482ee3 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,131 @@ +package com.ssafy.tiggle.data.repository + +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.auth.UserSignUp +import com.ssafy.tiggle.domain.repository.AuthRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthRepositoryImpl @Inject constructor( + private val authApiService: AuthApiService, + private val authDataSource: AuthDataSource +) : AuthRepository { + + override suspend fun signUpUser(userSignUp: UserSignUp): Result { + return try { + val signUpRequest = SignUpRequestDto( + email = userSignUp.email, + password = userSignUp.password, + name = userSignUp.name, + 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("AuthRepositoryImpl", "💥 네트워크 예외 발생: ${e.message}", e) + Result.failure(Exception("네트워크 연결을 확인해주세요.")) + } + } + + override suspend fun loginUser(email: String, password: String): Result { + return try { + val loginRequest = LoginRequestDto( + email = email, + password = password + ) + + val response = authApiService.login(loginRequest) + + if (response.isSuccessful) { + val body = response.body() + if (body != null && body.result) { + val newAccess = stripBearer(response.headers()["Authorization"]) + if (newAccess.isBlank()) { + Result.failure(Exception("인증 토큰을 받을 수 없습니다.")) + } else { + authDataSource.saveAccessToken(newAccess) + Result.success(Unit) + } + } else { + Result.failure(Exception(body?.message ?: "로그인에 실패했습니다.")) + } + } else { + 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(Exception("네트워크 연결을 확인해주세요.")) + } + } + + private fun stripBearer(authHeader: String?): String { + return authHeader?.removePrefix("Bearer ")?.trim() ?: "" + } +} + + diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/DutchPayRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/DutchPayRepositoryImpl.kt new file mode 100644 index 0000000..66d94b8 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/DutchPayRepositoryImpl.kt @@ -0,0 +1,36 @@ +package com.ssafy.tiggle.data.repository + +import com.ssafy.tiggle.data.datasource.remote.DutchPayApiService +import com.ssafy.tiggle.data.model.dutchpay.request.DutchPayRequestDto +import com.ssafy.tiggle.domain.entity.dutchpay.DutchPayRequest +import com.ssafy.tiggle.domain.repository.DutchPayRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DutchPayRepositoryImpl @Inject constructor( + private val dutchPayApiService: DutchPayApiService +) : DutchPayRepository { + + override suspend fun createDutchPayRequest(request: DutchPayRequest): Result { + return try { + val requestDto = DutchPayRequestDto( + userIds = request.userIds, + totalAmount = request.totalAmount, + title = request.title, + message = request.message, + payMore = request.payMore + ) + + val response = dutchPayApiService.createDutchPayRequest(requestDto) + + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("더치페이 요청 실패: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} 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 index 6116bd0..aa8a2ac 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/repository/UniversityRepositoryImpl.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/UniversityRepositoryImpl.kt @@ -4,8 +4,8 @@ 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.entity.auth.Department +import com.ssafy.tiggle.domain.entity.auth.University import com.ssafy.tiggle.domain.repository.UniversityRepository import javax.inject.Inject import javax.inject.Singleton @@ -25,7 +25,10 @@ class UniversityRepositoryImpl @Inject constructor( return try { Log.d("UniversityRepositoryImpl", "📤 대학교 목록 요청 전송 중...") val response = universityApiService.getUniversities() - Log.d("UniversityRepositoryImpl", "📥 대학교 목록 응답 수신: isSuccessful=${response.isSuccessful}, code=${response.code()}") + Log.d( + "UniversityRepositoryImpl", + "📥 대학교 목록 응답 수신: isSuccessful=${response.isSuccessful}, code=${response.code()}" + ) if (response.isSuccessful) { val body = response.body() @@ -39,7 +42,10 @@ class UniversityRepositoryImpl @Inject constructor( Result.failure(Exception(body?.message ?: "대학교 목록을 불러올 수 없습니다.")) } } else { - Log.d("UniversityRepositoryImpl", "❌ HTTP 실패: ${response.code()} ${response.message()}") + Log.d( + "UniversityRepositoryImpl", + "❌ HTTP 실패: ${response.code()} ${response.message()}" + ) val errorBody = response.errorBody()?.string() val message = when (response.code()) { 400 -> "잘못된 요청입니다." @@ -51,7 +57,8 @@ class UniversityRepositoryImpl @Inject constructor( else -> { if (!errorBody.isNullOrEmpty()) { try { - val errorResponse = Gson().fromJson(errorBody, BaseResponse::class.java) + val errorResponse = + Gson().fromJson(errorBody, BaseResponse::class.java) errorResponse.message ?: "대학교 목록을 불러올 수 없습니다. (${response.code()})" } catch (e: Exception) { "대학교 목록을 불러올 수 없습니다. (${response.code()})" @@ -74,7 +81,10 @@ class UniversityRepositoryImpl @Inject constructor( return try { Log.d("UniversityRepositoryImpl", "📤 학과 목록 요청 전송 중...") val response = universityApiService.getDepartments(universityId) - Log.d("UniversityRepositoryImpl", "📥 학과 목록 응답 수신: isSuccessful=${response.isSuccessful}, code=${response.code()}") + Log.d( + "UniversityRepositoryImpl", + "📥 학과 목록 응답 수신: isSuccessful=${response.isSuccessful}, code=${response.code()}" + ) if (response.isSuccessful) { val body = response.body() @@ -88,7 +98,10 @@ class UniversityRepositoryImpl @Inject constructor( Result.failure(Exception(body?.message ?: "학과 목록을 불러올 수 없습니다.")) } } else { - Log.d("UniversityRepositoryImpl", "❌ HTTP 실패: ${response.code()} ${response.message()}") + Log.d( + "UniversityRepositoryImpl", + "❌ HTTP 실패: ${response.code()} ${response.message()}" + ) val errorBody = response.errorBody()?.string() val message = when (response.code()) { 400 -> "잘못된 요청입니다." @@ -100,7 +113,8 @@ class UniversityRepositoryImpl @Inject constructor( else -> { if (!errorBody.isNullOrEmpty()) { try { - val errorResponse = Gson().fromJson(errorBody, BaseResponse::class.java) + val errorResponse = + Gson().fromJson(errorBody, BaseResponse::class.java) errorResponse.message ?: "학과 목록을 불러올 수 없습니다. (${response.code()})" } catch (e: Exception) { "학과 목록을 불러올 수 없습니다. (${response.code()})" 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 53c5e7e..bae8782 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,14 +1,8 @@ package com.ssafy.tiggle.data.repository -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.UserSignUp +import com.ssafy.tiggle.data.datasource.remote.UserApiService import com.ssafy.tiggle.domain.repository.UserRepository +import com.ssafy.tiggle.domain.entity.dutchpay.UserSummary import javax.inject.Inject import javax.inject.Singleton @@ -19,128 +13,26 @@ import javax.inject.Singleton */ @Singleton class UserRepositoryImpl @Inject constructor( - private val authApiService: AuthApiService, - private val authDataSource: AuthDataSource + private val userApiService: UserApiService ) : UserRepository { - override suspend fun signUpUser(userSignUp: UserSignUp): Result { - return try { - // 도메인 엔티티를 DTO로 변환 - val signUpRequest = SignUpRequestDto( - email = userSignUp.email, - password = userSignUp.password, - name = userSignUp.name, - 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 { + override suspend fun getAllUsers(): Result> { return try { - // 도메인 엔티티를 DTO로 변환 - val loginRequest = LoginRequestDto( - email = email, - password = password - ) - - val response = authApiService.login(loginRequest) - + val response = userApiService.getAllUsers() if (response.isSuccessful) { 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) - } + if (body != null && body.result && body.data != null) { + Result.success(body.data.map { it.toDomain() }) } else { - Result.failure(Exception(body?.message ?: "로그인에 실패했습니다.")) + Result.failure(Exception(body?.message ?: "사용자 목록을 가져오지 못했습니다.")) } } else { - 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)) + Result.failure(Exception("사용자 목록 조회 실패 (${response.code()})")) } } catch (e: Exception) { - Result.failure(Exception("네트워크 연결을 확인해주세요.")) + Result.failure(Exception("네트워크 오류: ${e.message}")) } } - /** - * Authorization 헤더에서 Bearer 접두사를 제거하는 유틸리티 함수 - */ - private fun stripBearer(authHeader: String?): String { - return authHeader?.removePrefix("Bearer ")?.trim() ?: "" - } } 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 151fbb1..45bbf20 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,15 @@ package com.ssafy.tiggle.di +import com.ssafy.tiggle.core.network.AuthInterceptor +import com.ssafy.tiggle.core.network.LoggingCookieJar +import com.ssafy.tiggle.core.network.PrettyHttpLoggingInterceptor import com.ssafy.tiggle.data.datasource.local.AuthDataSource import com.ssafy.tiggle.data.datasource.remote.AuthApiService -import com.ssafy.tiggle.data.datasource.remote.AuthInterceptor +import com.ssafy.tiggle.data.datasource.remote.DutchPayApiService import com.ssafy.tiggle.data.datasource.remote.FcmApiService import com.ssafy.tiggle.data.datasource.remote.PiggyBankApiService -import com.ssafy.tiggle.data.datasource.remote.PrettyHttpLoggingInterceptor import com.ssafy.tiggle.data.datasource.remote.UniversityApiService +import com.ssafy.tiggle.data.datasource.remote.UserApiService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -32,13 +35,19 @@ object NetworkModule { fun providePrettyHttpLoggingInterceptor(): PrettyHttpLoggingInterceptor = PrettyHttpLoggingInterceptor() + @Provides + @Singleton + fun provideCookieJar(): LoggingCookieJar = LoggingCookieJar() + /** 인증 없음: 로그인/재발급 등 */ @Provides @Singleton @Named("noAuthClient") fun provideNoAuthOkHttp( - pretty: PrettyHttpLoggingInterceptor + pretty: PrettyHttpLoggingInterceptor, + cookieJar: LoggingCookieJar ): OkHttpClient = OkHttpClient.Builder() + .cookieJar(cookieJar) .addInterceptor(pretty) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) @@ -86,8 +95,10 @@ object NetworkModule { @Named("authClient") fun provideAuthOkHttp( pretty: PrettyHttpLoggingInterceptor, - authInterceptor: AuthInterceptor + authInterceptor: AuthInterceptor, + cookieJar: LoggingCookieJar ): OkHttpClient = OkHttpClient.Builder() + .cookieJar(cookieJar) .addInterceptor(authInterceptor) // ⬅️ 토큰 붙임 .addInterceptor(pretty) // ⬅️ 로그 .connectTimeout(30, TimeUnit.SECONDS) @@ -121,4 +132,14 @@ object NetworkModule { @Singleton fun provideFcmApiService(retrofit: Retrofit): FcmApiService = retrofit.create(FcmApiService::class.java) + + @Provides + @Singleton + fun provideUserApiService(retrofit: Retrofit): UserApiService = + retrofit.create(UserApiService::class.java) + + @Provides + @Singleton + fun provideDutchPayApiService(retrofit: Retrofit): DutchPayApiService = + retrofit.create(DutchPayApiService::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 a644089..04aa6f1 100644 --- a/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt +++ b/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt @@ -1,9 +1,13 @@ package com.ssafy.tiggle.di +import com.ssafy.tiggle.data.repository.AuthRepositoryImpl +import com.ssafy.tiggle.data.repository.DutchPayRepositoryImpl import com.ssafy.tiggle.data.repository.FcmRepositoryImpl import com.ssafy.tiggle.data.repository.PiggyBankRepositoryImpl import com.ssafy.tiggle.data.repository.UniversityRepositoryImpl import com.ssafy.tiggle.data.repository.UserRepositoryImpl +import com.ssafy.tiggle.domain.repository.AuthRepository +import com.ssafy.tiggle.domain.repository.DutchPayRepository import com.ssafy.tiggle.domain.repository.FcmRepository import com.ssafy.tiggle.domain.repository.PiggyBankRepository import com.ssafy.tiggle.domain.repository.UniversityRepository @@ -28,6 +32,12 @@ abstract class RepositoryModule { userRepositoryImpl: UserRepositoryImpl ): UserRepository + @Binds + @Singleton + abstract fun bindAuthRepository( + authRepositoryImpl: AuthRepositoryImpl + ): AuthRepository + @Binds @Singleton abstract fun bindUniversityRepository( @@ -45,4 +55,10 @@ abstract class RepositoryModule { abstract fun bindFcmRepository( fcmRepositoryImpl: FcmRepositoryImpl ): FcmRepository + + @Binds + @Singleton + abstract fun bindDutchPayRepository( + dutchPayRepositoryImpl: DutchPayRepositoryImpl + ): DutchPayRepository } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/Department.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/Department.kt similarity index 70% rename from app/src/main/java/com/ssafy/tiggle/domain/entity/Department.kt rename to app/src/main/java/com/ssafy/tiggle/domain/entity/auth/Department.kt index df2175b..341e8cf 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/Department.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/Department.kt @@ -1,4 +1,4 @@ -package com.ssafy.tiggle.domain.entity +package com.ssafy.tiggle.domain.entity.auth /** * 학과 도메인 엔티티 diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/University.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/University.kt similarity index 70% rename from app/src/main/java/com/ssafy/tiggle/domain/entity/University.kt rename to app/src/main/java/com/ssafy/tiggle/domain/entity/auth/University.kt index bad041d..19e9a11 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/University.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/University.kt @@ -1,4 +1,4 @@ -package com.ssafy.tiggle.domain.entity +package com.ssafy.tiggle.domain.entity.auth /** * 대학교 도메인 엔티티 diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/User.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/User.kt similarity index 85% rename from app/src/main/java/com/ssafy/tiggle/domain/entity/User.kt rename to app/src/main/java/com/ssafy/tiggle/domain/entity/auth/User.kt index 2d52880..ace6565 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/User.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/User.kt @@ -1,4 +1,4 @@ -package com.ssafy.tiggle.domain.entity +package com.ssafy.tiggle.domain.entity.auth /** * 사용자 도메인 엔티티 @@ -12,4 +12,4 @@ data class User( val studentId: String, val createdAt: String? = null, val updatedAt: String? = null -) +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/UserSignUp.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/UserSignUp.kt similarity index 99% rename from app/src/main/java/com/ssafy/tiggle/domain/entity/UserSignUp.kt rename to app/src/main/java/com/ssafy/tiggle/domain/entity/auth/UserSignUp.kt index 87a2afe..4c1074d 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/UserSignUp.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/auth/UserSignUp.kt @@ -1,4 +1,4 @@ -package com.ssafy.tiggle.domain.entity +package com.ssafy.tiggle.domain.entity.auth import android.util.Patterns diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/dutchpay/DutchPayRequest.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/dutchpay/DutchPayRequest.kt new file mode 100644 index 0000000..8725f29 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/dutchpay/DutchPayRequest.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.domain.entity.dutchpay + +data class DutchPayRequest( + val userIds: List, + val totalAmount: Long, + val title: String, + val message: String, + val payMore: Boolean +) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/dutchpay/UserSummary.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/dutchpay/UserSummary.kt new file mode 100644 index 0000000..cccff60 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/dutchpay/UserSummary.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.domain.entity.dutchpay + +/** + * 사용자 요약 도메인 엔티티 (리스트/피커용) + */ +data class UserSummary( + val id: Long, + val name: String +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/AuthRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..2669a3d --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/AuthRepository.kt @@ -0,0 +1,10 @@ +package com.ssafy.tiggle.domain.repository + +import com.ssafy.tiggle.domain.entity.auth.UserSignUp + +interface AuthRepository { + 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/repository/DutchPayRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/DutchPayRepository.kt new file mode 100644 index 0000000..8efe0a4 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/DutchPayRepository.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.domain.repository + +import com.ssafy.tiggle.domain.entity.dutchpay.DutchPayRequest + +interface DutchPayRepository { + suspend fun createDutchPayRequest(request: DutchPayRequest): Result +} 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 index b325816..1496e9c 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/repository/UniversityRepository.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/UniversityRepository.kt @@ -1,7 +1,7 @@ package com.ssafy.tiggle.domain.repository -import com.ssafy.tiggle.domain.entity.Department -import com.ssafy.tiggle.domain.entity.University +import com.ssafy.tiggle.domain.entity.auth.Department +import com.ssafy.tiggle.domain.entity.auth.University /** * 대학교/학과 관련 Repository 인터페이스 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 c1ea14e..6780b31 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 @@ -1,7 +1,6 @@ package com.ssafy.tiggle.domain.repository -import com.ssafy.tiggle.domain.entity.User -import com.ssafy.tiggle.domain.entity.UserSignUp +import com.ssafy.tiggle.domain.entity.dutchpay.UserSummary /** * 사용자 관련 Repository 인터페이스 @@ -11,19 +10,7 @@ import com.ssafy.tiggle.domain.entity.UserSignUp interface UserRepository { /** - * 회원가입 - * - * @param userSignUp 회원가입 데이터 - * @return Result 성공 시 Unit, 실패 시 에러 + * 전체 사용자 목록 조회 (간단 정보) */ - suspend fun signUpUser(userSignUp: UserSignUp): Result - - /** - * 로그인 - * - * @param email 이메일 - * @param password 비밀번호 - * @return Result 성공 시 Unit, 실패 시 에러 - */ - suspend fun loginUser(email: String, password: String): Result + suspend fun getAllUsers(): 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/auth/GetDepartmentsUseCase.kt similarity index 85% rename from app/src/main/java/com/ssafy/tiggle/domain/usecase/GetDepartmentsUseCase.kt rename to app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/GetDepartmentsUseCase.kt index f6d716e..bbe7d55 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetDepartmentsUseCase.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/GetDepartmentsUseCase.kt @@ -1,12 +1,12 @@ -package com.ssafy.tiggle.domain.usecase +package com.ssafy.tiggle.domain.usecase.auth -import com.ssafy.tiggle.domain.entity.Department +import com.ssafy.tiggle.domain.entity.auth.Department import com.ssafy.tiggle.domain.repository.UniversityRepository import javax.inject.Inject /** * 학과 목록 조회 UseCase - * + * * 학과 목록 조회 비즈니스 로직을 처리합니다. */ class GetDepartmentsUseCase @Inject constructor( @@ -14,7 +14,7 @@ class GetDepartmentsUseCase @Inject constructor( ) { /** * 학과 목록 조회 실행 - * + * * @param universityId 대학교 ID * @return Result> 성공 시 학과 목록, 실패 시 에러 */ diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetUniversitiesUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/GetUniversitiesUseCase.kt similarity index 84% rename from app/src/main/java/com/ssafy/tiggle/domain/usecase/GetUniversitiesUseCase.kt rename to app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/GetUniversitiesUseCase.kt index b947812..490cd0b 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/usecase/GetUniversitiesUseCase.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/GetUniversitiesUseCase.kt @@ -1,12 +1,12 @@ -package com.ssafy.tiggle.domain.usecase +package com.ssafy.tiggle.domain.usecase.auth -import com.ssafy.tiggle.domain.entity.University +import com.ssafy.tiggle.domain.entity.auth.University import com.ssafy.tiggle.domain.repository.UniversityRepository import javax.inject.Inject /** * 대학교 목록 조회 UseCase - * + * * 대학교 목록 조회 비즈니스 로직을 처리합니다. */ class GetUniversitiesUseCase @Inject constructor( @@ -14,7 +14,7 @@ class GetUniversitiesUseCase @Inject constructor( ) { /** * 대학교 목록 조회 실행 - * + * * @return Result> 성공 시 대학교 목록, 실패 시 에러 */ suspend operator fun invoke(): Result> { diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/LoginUserUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/LoginUserUseCase.kt similarity index 75% rename from app/src/main/java/com/ssafy/tiggle/domain/usecase/LoginUserUseCase.kt rename to app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/LoginUserUseCase.kt index 2785de2..84d8f6b 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/usecase/LoginUserUseCase.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/LoginUserUseCase.kt @@ -1,19 +1,19 @@ -package com.ssafy.tiggle.domain.usecase +package com.ssafy.tiggle.domain.usecase.auth -import com.ssafy.tiggle.domain.repository.UserRepository +import com.ssafy.tiggle.domain.repository.AuthRepository import javax.inject.Inject /** * 로그인 UseCase - * + * * 로그인 비즈니스 로직을 처리합니다. */ class LoginUserUseCase @Inject constructor( - private val userRepository: UserRepository + private val authRepository: AuthRepository ) { /** * 로그인 실행 - * + * * @param email 이메일 * @param password 비밀번호 * @return Result 성공 시 Unit, 실패 시 에러 @@ -25,8 +25,8 @@ class LoginUserUseCase @Inject constructor( IllegalArgumentException("이메일과 비밀번호를 입력해주세요.") ) } - + // 로그인 실행 - return userRepository.loginUser(email, password) + return authRepository.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/auth/SignUpUserUseCase.kt similarity index 73% rename from app/src/main/java/com/ssafy/tiggle/domain/usecase/SignUpUserUseCase.kt rename to app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/SignUpUserUseCase.kt index 5d55f03..943e76d 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/usecase/SignUpUserUseCase.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/auth/SignUpUserUseCase.kt @@ -1,20 +1,20 @@ -package com.ssafy.tiggle.domain.usecase +package com.ssafy.tiggle.domain.usecase.auth -import com.ssafy.tiggle.domain.entity.UserSignUp -import com.ssafy.tiggle.domain.repository.UserRepository +import com.ssafy.tiggle.domain.entity.auth.UserSignUp +import com.ssafy.tiggle.domain.repository.AuthRepository import javax.inject.Inject /** * 회원가입 UseCase - * + * * 회원가입 비즈니스 로직을 처리합니다. */ class SignUpUserUseCase @Inject constructor( - private val userRepository: UserRepository + private val authRepository: AuthRepository ) { /** * 회원가입 실행 - * + * * @param userSignUp 회원가입 데이터 * @return Result 성공 시 생성된 사용자 정보, 실패 시 에러 */ @@ -28,6 +28,6 @@ class SignUpUserUseCase @Inject constructor( } // 2. 회원가입 실행 후 결과 반환 - return userRepository.signUpUser(validatedData) + return authRepository.signUpUser(validatedData) } } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/dutchpay/CreateDutchPayRequestUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/dutchpay/CreateDutchPayRequestUseCase.kt new file mode 100644 index 0000000..674714d --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/dutchpay/CreateDutchPayRequestUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.dutchpay + +import com.ssafy.tiggle.domain.entity.dutchpay.DutchPayRequest +import com.ssafy.tiggle.domain.repository.DutchPayRepository +import javax.inject.Inject + +class CreateDutchPayRequestUseCase @Inject constructor( + private val dutchPayRepository: DutchPayRepository +) { + suspend operator fun invoke(request: DutchPayRequest): Result { + return dutchPayRepository.createDutchPayRequest(request) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/dutchpay/GetAllUsersUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/dutchpay/GetAllUsersUseCase.kt new file mode 100644 index 0000000..bcb00c7 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/dutchpay/GetAllUsersUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.dutchpay + +import com.ssafy.tiggle.domain.entity.dutchpay.UserSummary +import com.ssafy.tiggle.domain.repository.UserRepository +import javax.inject.Inject + +class GetAllUsersUseCase @Inject constructor( + private val userRepository: UserRepository +) { + suspend operator fun invoke(): Result> { + return userRepository.getAllUsers() + } +} \ No newline at end of file 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 39708c6..7b1ea31 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 @@ -14,6 +14,7 @@ import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import com.ssafy.tiggle.presentation.ui.auth.login.LoginScreen import com.ssafy.tiggle.presentation.ui.auth.signup.SignUpScreen +import com.ssafy.tiggle.presentation.ui.dutchpay.CreateDutchPayScreen import com.ssafy.tiggle.presentation.ui.piggybank.OpenAccountScreen import com.ssafy.tiggle.presentation.ui.piggybank.PiggyBankScreen import com.ssafy.tiggle.presentation.ui.piggybank.RegisterAccountScreen @@ -82,6 +83,9 @@ fun NavigationGraph() { onRegisterAccountClick = { navBackStack.add(Screen.RegisterAccount) }, + onStartDutchPayClick = { + navBackStack.add(Screen.CreateDutchPay) + }, onBackClick = { navBackStack.removeLastOrNull() } @@ -101,6 +105,13 @@ fun NavigationGraph() { ) } + is Screen.CreateDutchPay -> NavEntry(key) { + CreateDutchPayScreen( + onBackClick = { navBackStack.removeLastOrNull() }, + onFinish = { navBackStack.removeLastOrNull() } + ) + } + else -> throw IllegalArgumentException("Unknown route: $key") } diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt index 19874c7..ab72fac 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt @@ -32,4 +32,7 @@ sealed interface Screen : NavKey { @Serializable object RegisterAccount : Screen + + @Serializable + object CreateDutchPay : Screen } 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 1fdee60..e5e0b54 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 @@ -4,7 +4,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ssafy.tiggle.core.fcm.FcmTokenUploader -import com.ssafy.tiggle.domain.usecase.LoginUserUseCase +import com.ssafy.tiggle.domain.usecase.auth.LoginUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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 3866303..8b4b2e2 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 @@ -21,7 +21,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import com.ssafy.tiggle.domain.entity.UserSignUp +import com.ssafy.tiggle.domain.entity.auth.UserSignUp +import com.ssafy.tiggle.domain.entity.auth.Department +import com.ssafy.tiggle.domain.entity.auth.University import com.ssafy.tiggle.presentation.ui.components.TiggleAllAgreeCheckboxItem import com.ssafy.tiggle.presentation.ui.components.TiggleButton import com.ssafy.tiggle.presentation.ui.components.TiggleButtonVariant @@ -431,10 +433,12 @@ private fun SchoolInformationScreen( // 드롭다운에 표시할 학교/학과 이름 목록 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 ?: "" + 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, @@ -718,12 +722,12 @@ private fun SignUpSchoolScreenPreview() { universityId = "1" ), universities = listOf( - com.ssafy.tiggle.domain.entity.University(1, "SSAFY"), - com.ssafy.tiggle.domain.entity.University(2, "서울대학교") + University(1, "SSAFY"), + University(2, "서울대학교") ), departments = listOf( - com.ssafy.tiggle.domain.entity.Department(1, "컴퓨터공학과"), - com.ssafy.tiggle.domain.entity.Department(2, "소프트웨어학과") + Department(1, "컴퓨터공학과"), + Department(2, "소프트웨어학과") ) ) @@ -756,8 +760,8 @@ private fun SignUpLoadingScreenPreview() { universityId = "1" ), universities = listOf( - com.ssafy.tiggle.domain.entity.University(1, "SSAFY"), - com.ssafy.tiggle.domain.entity.University(2, "서울대학교") + University(1, "SSAFY"), + University(2, "서울대학교") ), isUniversitiesLoading = true ) 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 0954977..2ca48a1 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,8 +1,8 @@ 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 +import com.ssafy.tiggle.domain.entity.auth.Department +import com.ssafy.tiggle.domain.entity.auth.University +import com.ssafy.tiggle.domain.entity.auth.UserSignUp /** * 회원가입 전체 과정의 UI 상태 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 975572a..b1b87bb 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 @@ -3,10 +3,10 @@ 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 com.ssafy.tiggle.domain.entity.auth.ValidationField +import com.ssafy.tiggle.domain.usecase.auth.GetDepartmentsUseCase +import com.ssafy.tiggle.domain.usecase.auth.GetUniversitiesUseCase +import com.ssafy.tiggle.domain.usecase.auth.SignUpUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -85,7 +85,7 @@ class SignUpViewModel @Inject constructor( val currentData = _uiState.value.userData val newData = currentData.copy(universityId = school).validateField(ValidationField.SCHOOL) _uiState.value = _uiState.value.copy(userData = newData) - + // 학교가 변경되면 해당 학교의 학과 목록을 불러옴 if (school.isNotBlank()) { loadDepartments(school.toLongOrNull() ?: return) @@ -235,7 +235,7 @@ class SignUpViewModel @Inject constructor( fun loadUniversities() { Log.d("SignUpViewModel", "🏫 대학교 목록 로드 시작") _uiState.value = _uiState.value.copy(isUniversitiesLoading = true) - + viewModelScope.launch { getUniversitiesUseCase() .onSuccess { universities -> @@ -259,7 +259,7 @@ class SignUpViewModel @Inject constructor( private fun loadDepartments(universityId: Long) { Log.d("SignUpViewModel", "🎓 학과 목록 로드 시작 (대학교 ID: $universityId)") _uiState.value = _uiState.value.copy(isDepartmentsLoading = true) - + viewModelScope.launch { getDepartmentsUseCase(universityId) .onSuccess { departments -> diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleScreenLayout.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleScreenLayout.kt index 96186e7..537e6f5 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleScreenLayout.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleScreenLayout.kt @@ -98,7 +98,7 @@ private fun TiggleScreenLayoutPreview() { fontSize = 20.sp, fontWeight = FontWeight.Bold ) - + 23 Spacer(modifier = Modifier.height(16.dp)) Text( diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleSwitchRow.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleSwitchRow.kt new file mode 100644 index 0000000..7d1db35 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleSwitchRow.kt @@ -0,0 +1,65 @@ +package com.ssafy.tiggle.presentation.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ssafy.tiggle.presentation.ui.theme.AppTypography +import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue +import com.ssafy.tiggle.presentation.ui.theme.TiggleGray +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText + +/** + * 라벨 + 서브타이틀이 있는 공통 스위치 행 컴포넌트 + */ +@Composable +fun TiggleSwitchRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 19.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, color = Color.Black, fontSize = 15.sp, style = AppTypography.bodyLarge) + Spacer(Modifier.height(4.dp)) + Text( + subtitle, + color = TiggleGrayText, + fontSize = 10.sp, + style = AppTypography.bodySmall + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = TiggleBlue, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = TiggleGray + ), + modifier = Modifier.size(10.dp, 5.dp) + ) + } +} + + diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleTextField.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleTextField.kt index 43b7db8..7295920 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleTextField.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/TiggleTextField.kt @@ -45,7 +45,9 @@ fun TiggleTextField( keyboardType: KeyboardType = KeyboardType.Text, modifier: Modifier = Modifier, isError: Boolean = false, - errorMessage: String? = null + errorMessage: String? = null, + maxLines: Int = 1, + minLines: Int = 1 ) { var isPasswordVisible by remember { mutableStateOf(false) } @@ -95,7 +97,9 @@ fun TiggleTextField( keyboardOptions = KeyboardOptions( keyboardType = keyboardType ), - singleLine = true, + singleLine = maxLines == 1, + maxLines = maxLines, + minLines = minLines, shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( unfocusedBorderColor = if (isError) MaterialTheme.colorScheme.error else TiggleGrayLight, diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/UserPicker.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/UserPicker.kt new file mode 100644 index 0000000..184eff3 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/components/UserPicker.kt @@ -0,0 +1,186 @@ +package com.ssafy.tiggle.presentation.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ssafy.tiggle.R +import com.ssafy.tiggle.domain.entity.dutchpay.UserSummary +import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayLight +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText + +@Composable +fun UserPicker( + users: List, + selectedUserIds: Set, + onToggleUser: (Long) -> Unit, + modifier: Modifier = Modifier +) { + var query by rememberSaveable { mutableStateOf("") } + val filteredUsers = remember(users, query) { + if (query.isBlank()) users else users.filter { it.name.contains(query, ignoreCase = true) } + } + + Column(modifier = modifier) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + placeholder = { Text(text = "이름으로 검색", color = TiggleGrayText) }, + singleLine = true, + trailingIcon = { + if (query.isNotBlank()) { + IconButton(onClick = { query = "" }) { + Icon(imageVector = Icons.Default.Close, contentDescription = "clear") + } + } + }, + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = TiggleGrayLight, + focusedBorderColor = TiggleBlue + ) + ) + // 선택된 사용자 칩 영역 + if (selectedUserIds.isNotEmpty()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + users.filter { selectedUserIds.contains(it.id) }.forEach { user -> + SelectedUserChip( + name = user.name, + onRemove = { onToggleUser(user.id) }, + modifier = Modifier.padding(end = 8.dp, bottom = 8.dp) + ) + } + } + Spacer(Modifier.height(8.dp)) + } + + // 사용자 리스트 + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + LazyColumn { + items(filteredUsers, key = { it.id }) { user -> + UserRow( + user = user, + isSelected = selectedUserIds.contains(user.id), + onClick = { onToggleUser(user.id) } + ) + } + } + } + } +} + +@Composable +private fun UserRow(user: UserSummary, isSelected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 아바타 (이니셜) + Box( + modifier = Modifier + .size(32.dp) + .background( + color = if (isSelected) TiggleBlue else TiggleGrayLight, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = user.name.firstOrNull()?.toString() ?: "?", + color = if (isSelected) Color.White else Color.Black, + fontWeight = FontWeight.Bold + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Text(text = user.name, color = Color.Black) + } + + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.check), + contentDescription = null, + tint = if (isSelected) TiggleBlue else TiggleGrayText + ) + } +} + +@Composable +private fun SelectedUserChip(name: String, onRemove: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background(TiggleGrayLight, RoundedCornerShape(16.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = name, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.size(6.dp)) + Box( + modifier = Modifier + .size(18.dp) + .background(Color.White, CircleShape) + .clickable { onRemove() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = "remove", + tint = TiggleGrayText, + modifier = Modifier.size(12.dp) + ) + } + } +} + + diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayScreen.kt new file mode 100644 index 0000000..7c33d9c --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayScreen.kt @@ -0,0 +1,699 @@ +package com.ssafy.tiggle.presentation.ui.dutchpay + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ssafy.tiggle.R +import com.ssafy.tiggle.core.utils.Formatter +import com.ssafy.tiggle.domain.entity.dutchpay.UserSummary +import com.ssafy.tiggle.presentation.ui.components.TiggleButton +import com.ssafy.tiggle.presentation.ui.components.TiggleButtonVariant +import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout +import com.ssafy.tiggle.presentation.ui.components.TiggleSwitchRow +import com.ssafy.tiggle.presentation.ui.components.TiggleTextField +import com.ssafy.tiggle.presentation.ui.components.UserPicker +import com.ssafy.tiggle.presentation.ui.theme.AppTypography +import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayLight +import kotlin.math.ceil + +@Composable +fun CreateDutchPayScreen( + onBackClick: () -> Unit = {}, + onFinish: () -> Unit = {}, + viewModel: CreateDutchPayViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadUsers() + } + + TiggleScreenLayout( + title = when (uiState.step) { + CreateDutchPayStep.PICK_USERS -> "유저 선택" + CreateDutchPayStep.INPUT_AMOUNT -> "결제 금액 입력" + CreateDutchPayStep.COMPLETE -> "요청 전송 완료" + }, + onBackClick = { + if (uiState.step == CreateDutchPayStep.PICK_USERS) onBackClick() else viewModel.goPrev() + }, + bottomButton = { + TiggleButton( + text = when (uiState.step) { + CreateDutchPayStep.PICK_USERS -> "다음" + CreateDutchPayStep.INPUT_AMOUNT -> "요청 보내기" + CreateDutchPayStep.COMPLETE -> "완료" + }, + onClick = { + when (uiState.step) { + CreateDutchPayStep.PICK_USERS -> viewModel.goNext() + CreateDutchPayStep.INPUT_AMOUNT -> viewModel.goNext() + CreateDutchPayStep.COMPLETE -> onFinish() + } + }, + enabled = when (uiState.step) { + CreateDutchPayStep.PICK_USERS -> uiState.selectedUserIds.isNotEmpty() + CreateDutchPayStep.INPUT_AMOUNT -> uiState.amountText.isNotBlank() && uiState.title.isNotBlank() + CreateDutchPayStep.COMPLETE -> true + }, + isLoading = uiState.isLoading, + variant = TiggleButtonVariant.Primary + ) + } + ) { + when (uiState.step) { + CreateDutchPayStep.PICK_USERS -> { + DutchPayPickUsersContent( + users = uiState.users, + selectedUserIds = uiState.selectedUserIds, + onToggleUser = viewModel::toggleUser + ) + } + + CreateDutchPayStep.INPUT_AMOUNT -> { + DutchPayInputAmountContent( + selectedUsers = uiState.users.filter { uiState.selectedUserIds.contains(it.id) }, + amountText = uiState.amountText, + onAmountChange = viewModel::updateAmount, + selectedCount = uiState.selectedUserIds.size, + payMore = uiState.payMore, + onPayMoreChange = viewModel::setPayMore, + title = uiState.title, + onTitleChange = viewModel::updateTitle, + message = uiState.message, + onMessageChange = viewModel::updateMessage + ) + } + + CreateDutchPayStep.COMPLETE -> { + DutchPayCompleteContent( + totalAmount = uiState.amountText.toLongOrNull() ?: 0L, + selectedUsers = uiState.users.filter { uiState.selectedUserIds.contains(it.id) }, + payMore = uiState.payMore + ) + } + } + } + + // 에러 다이얼로그 표시 + uiState.errorMessage?.let { errorMessage -> + AlertDialog( + onDismissRequest = { viewModel.clearErrorMessage() }, + title = { + Text("오류") + }, + text = { + Text(errorMessage) + }, + confirmButton = { + TextButton( + onClick = { viewModel.clearErrorMessage() } + ) { + Text("확인") + } + } + ) + } +} + +@Composable +fun DutchPayPickUsersContent( + users: List, + selectedUserIds: Set, + onToggleUser: (Long) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 12.dp) + ) { + Text("함께 결제할 유저를 선택하세요", style = AppTypography.bodyLarge) + Spacer(Modifier.height(12.dp)) + UserPicker( + users = users, + selectedUserIds = selectedUserIds, + onToggleUser = onToggleUser + ) + } +} + +@Composable +fun DutchPayInputAmountContent( + selectedUsers: List, + amountText: String, + onAmountChange: (String) -> Unit, + selectedCount: Int, + payMore: Boolean, + onPayMoreChange: (Boolean) -> Unit, + title: String, + onTitleChange: (String) -> Unit, + message: String, + onMessageChange: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp) + ) { + // 선택한 친구 섹션 + if (selectedUsers.isNotEmpty()) { + Text("선택한 친구 (${selectedUsers.size}명)", style = AppTypography.bodyLarge) + Spacer(Modifier.height(8.dp)) + FlowRow(modifier = Modifier.fillMaxWidth()) { + selectedUsers.forEach { user -> + SelectedUserBadge(name = user.name) + } + } + Spacer(Modifier.height(16.dp)) + } + + // 더치페이 제목 + Spacer(Modifier.height(12.dp)) + + AmountInputCard( + value = amountText, + onValueChange = onAmountChange + ) + Spacer(Modifier.height(16.dp)) + // 내가 더 낼게요 스위치 + TiggleSwitchRow( + title = "내가 더 낼게요", + subtitle = "자투리 금액을 티끌 저금통에 적립", + checked = payMore, + onCheckedChange = onPayMoreChange + ) + Spacer(Modifier.height(16.dp)) + TiggleTextField( + value = title, + onValueChange = onTitleChange, + label = "더치페이 제목", + placeholder = "예: 어제 먹은 치킨 정산" + ) + + + + Spacer(Modifier.height(16.dp)) + + // 전달 메시지 + TiggleTextField( + value = message, + onValueChange = onMessageChange, + label = "전달 메시지", + placeholder = "친구들에게 전달할 메시지를 입력해주세요\n(예: 오늘 치킨 먹은 금액입니다!)", + + maxLines = 3, + minLines = 3 + ) + + // 결제 요약 (입력값과 선택 인원 있을 때만 표시) + val total = amountText.toLongOrNull() ?: 0L + val participantCount = if (selectedCount > 0) selectedCount + 1 else 0 + if (total > 0 && participantCount > 0) { + Spacer(Modifier.height(16.dp)) + DutchPaySummary( + totalAmount = total, + participantCount = participantCount, + payMore = payMore + ) + } + } +} + +@Composable +private fun SelectedUserBadge(name: String) { + Row( + modifier = Modifier + .padding(end = 8.dp, bottom = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .background(TiggleGrayLight) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = name, style = AppTypography.bodySmall) + } +} + +@Composable +private fun DutchPaySummary(totalAmount: Long, participantCount: Int, payMore: Boolean) { + // 1인당 금액 계산 + val perHead = if (participantCount > 0) totalAmount.toDouble() / participantCount else 0.0 + val myAmount = if (payMore) roundUpToHundreds(perHead) else perHead.toLong() + + Card( + modifier = Modifier.fillMaxSize(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("결제 요약", style = AppTypography.bodyLarge) + Spacer(Modifier.height(12.dp)) + SummaryRow(label = "총 금액", value = Formatter.formatCurrency(totalAmount)) + + Spacer(Modifier.height(8.dp)) + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + Spacer(Modifier.height(8.dp)) + + SummaryRow(label = "참여 인원", value = "${participantCount}명(나 포함)") + + Spacer(Modifier.height(8.dp)) + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + Spacer(Modifier.height(8.dp)) + SummaryRow(label = "1인당 금액", value = Formatter.formatCurrency(perHead.toLong())) + Spacer(Modifier.height(8.dp)) + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + Spacer(Modifier.height(8.dp)) + SummaryRow(label = "내 결제 금액", value = Formatter.formatCurrency(myAmount)) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun SummaryRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, style = AppTypography.bodySmall) + Text(text = value, style = AppTypography.bodySmall) + } +} + +private fun roundUpToHundreds(value: Double): Long { + // 100원 단위 올림 + val asLong = ceil(value / 100.0) * 100.0 + return asLong.toLong() +} + +// Formatter를 사용하므로 로컬 함수는 제거 + +@Composable +fun DutchPayCompleteContent( + totalAmount: Long = 50000L, + selectedUsers: List = emptyList(), + payMore: Boolean = false +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(40.dp)) + + // 아이콘 + Box( + modifier = Modifier + .size(150.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.wallet), + contentDescription = "Wallet", + modifier = Modifier.fillMaxSize() + ) + } + + Spacer(Modifier.height(24.dp)) + + // 제목 + Text( + text = "더치페이 요청 완료", + style = AppTypography.headlineMedium.copy( + fontWeight = FontWeight.Bold + ), + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(8.dp)) + + // 설명 + Text( + text = "친구들에게 더치페이 요청을 보냈습니다.", + style = AppTypography.bodyMedium, + color = Color.Gray, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(32.dp)) + + // 요약 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "요청 금액", + style = AppTypography.bodyMedium, + color = Color.Gray + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = Formatter.formatCurrency(totalAmount), + style = AppTypography.headlineLarge.copy( + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + ) + + Spacer(Modifier.height(16.dp)) + + HorizontalDivider(color = Color.Gray.copy(alpha = 0.2f)) + + Spacer(Modifier.height(16.dp)) + + // 참여자 정보 + val participantCount = selectedUsers.size + 1 // 나 포함 + val perHead = + if (participantCount > 0) totalAmount.toDouble() / participantCount else 0.0 + val myAmount = if (payMore) roundUpToHundreds(perHead) else perHead.toLong() + val friendAmount = perHead.toLong() + + DetailRow( + label = "참여자 (${participantCount}명)", + value = Formatter.formatCurrency(myAmount) + ) + + if (selectedUsers.isNotEmpty()) { + selectedUsers.forEach { user -> + Spacer(Modifier.height(12.dp)) + DetailRow( + label = user.name, + value = Formatter.formatCurrency(friendAmount) + ) + } + } + } + } + + Spacer(Modifier.height(24.dp)) + + // 안내 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = TiggleBlue.copy(alpha = 0.1f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.Top + ) { + Text( + text = "💰", + style = AppTypography.bodyMedium, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = "다음 단계", + style = AppTypography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + color = TiggleBlue + ) + ) + } + + Spacer(Modifier.height(8.dp)) + + InfoStep(text = "친구들이 더치페이 요청을 확인합니다.") + InfoStep(text = "각자 승인 후 결제를 진행합니다.") + InfoStep(text = "모든 승인이 완료되면 정산이 진행됩니다.") + } + } + + Spacer(Modifier.height(40.dp)) + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = AppTypography.bodyMedium, + color = Color.Gray + ) + Text( + text = value, + style = AppTypography.bodyMedium.copy( + fontWeight = FontWeight.Medium + ) + ) + } +} + +@Composable +private fun InfoStep(text: String) { + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = "📋", + style = AppTypography.bodySmall, + modifier = Modifier.padding(end = 8.dp, top = 2.dp) + ) + Text( + text = text, + style = AppTypography.bodySmall, + color = Color.Gray + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewDutchPay_PickUsers() { + val sampleUsers = listOf( + UserSummary(4, "김테스트"), + UserSummary(5, "박테스트"), + UserSummary(6, "이테스트") + ) + val uiState = CreateDutchPayState( + step = CreateDutchPayStep.PICK_USERS, + users = sampleUsers, + selectedUserIds = setOf(4, 6) + ) + + TiggleScreenLayout( + title = "유저 선택", + onBackClick = {}, + bottomButton = { + TiggleButton( + text = "다음", + onClick = {}, + enabled = uiState.selectedUserIds.isNotEmpty(), + isLoading = false, + variant = TiggleButtonVariant.Primary + ) + } + ) { + DutchPayPickUsersContent( + users = uiState.users, + selectedUserIds = uiState.selectedUserIds, + onToggleUser = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewDutchPay_InputAmount() { + val uiState = CreateDutchPayState( + step = CreateDutchPayStep.INPUT_AMOUNT, + amountText = "45000" + ) + + TiggleScreenLayout( + title = "결제 금액 입력", + onBackClick = {}, + bottomButton = { + TiggleButton( + text = "요청 보내기", + onClick = {}, + enabled = uiState.amountText.isNotBlank(), + isLoading = false, + variant = TiggleButtonVariant.Primary + ) + } + ) { + DutchPayInputAmountContent( + selectedUsers = listOf( + UserSummary(1, "김민호"), + UserSummary(2, "민경이"), + UserSummary(3, "홍길동") + ), + amountText = uiState.amountText, + onAmountChange = {}, + selectedCount = 3, + payMore = true, + onPayMoreChange = {}, + title = "", + onTitleChange = {}, + message = "", + onMessageChange = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewDutchPay_Complete() { + TiggleScreenLayout( + title = "요청 전송 완료", + onBackClick = {}, + bottomButton = { + TiggleButton( + text = "완료", + onClick = {}, + enabled = true, + isLoading = false, + variant = TiggleButtonVariant.Primary + ) + } + ) { + DutchPayCompleteContent( + totalAmount = 50000L, + selectedUsers = listOf( + UserSummary(1, "김민준"), + UserSummary(2, "박예준") + ), + payMore = true + ) + } +} + +@Composable +private fun AmountInputCard( + value: String, + onValueChange: (String) -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "총 결제 금액", + style = AppTypography.bodyMedium, + color = Color.Gray + ) + + Spacer(Modifier.height(8.dp)) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (value.isEmpty()) { + Text( + text = "50,000", + style = AppTypography.headlineLarge.copy( + fontSize = 32.sp, + fontWeight = FontWeight.Bold + ), + color = Color.Gray.copy(alpha = 0.4f) + ) + } + + BasicTextField( + value = if (value.isNotEmpty()) Formatter.formatCurrency( + value.toLongOrNull() ?: 0L + ) else "", + onValueChange = { text -> + val digitsOnly = text.filter { it.isDigit() } + onValueChange(digitsOnly) + }, + textStyle = AppTypography.headlineLarge.copy( + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + } + + + } + + } + + Spacer(Modifier.height(4.dp)) + + Text( + text = "숫자만 입력하세요 (쉼표 자동 추가)", + style = AppTypography.bodySmall, + color = Color.Gray + ) + } + +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayStep.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayStep.kt new file mode 100644 index 0000000..f124c24 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayStep.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.presentation.ui.dutchpay + +enum class CreateDutchPayStep { + PICK_USERS, + INPUT_AMOUNT, + COMPLETE +} + + diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayUiState.kt new file mode 100644 index 0000000..9d6d52c --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayUiState.kt @@ -0,0 +1,15 @@ +package com.ssafy.tiggle.presentation.ui.dutchpay + +import com.ssafy.tiggle.domain.entity.dutchpay.UserSummary + +data class CreateDutchPayState( + val step: CreateDutchPayStep = CreateDutchPayStep.PICK_USERS, + val users: List = emptyList(), + val selectedUserIds: Set = emptySet(), + val amountText: String = "", + val payMore: Boolean = false, + val title: String = "", + val message: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null +) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayViewModel.kt new file mode 100644 index 0000000..92d4f15 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/dutchpay/CreateDutchPayViewModel.kt @@ -0,0 +1,146 @@ +package com.ssafy.tiggle.presentation.ui.dutchpay + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ssafy.tiggle.domain.entity.dutchpay.DutchPayRequest +import com.ssafy.tiggle.domain.usecase.dutchpay.GetAllUsersUseCase +import com.ssafy.tiggle.domain.usecase.dutchpay.CreateDutchPayRequestUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class CreateDutchPayViewModel @Inject constructor( + private val getAllUsersUseCase: GetAllUsersUseCase, + private val createDutchPayRequestUseCase: CreateDutchPayRequestUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(CreateDutchPayState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadUsers() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + getAllUsersUseCase() + .onSuccess { list -> + _uiState.update { it.copy(users = list, isLoading = false) } + } + .onFailure { e -> + _uiState.update { it.copy(isLoading = false, errorMessage = e.message) } + } + } + } + + fun toggleUser(userId: Long) { + _uiState.update { current -> + val next = current.selectedUserIds.toMutableSet().apply { + if (contains(userId)) remove(userId) else add(userId) + } + current.copy(selectedUserIds = next) + } + } + + fun updateAmount(text: String) { + _uiState.update { it.copy(amountText = text.filter { ch -> ch.isDigit() }) } + } + + fun setPayMore(value: Boolean) { + _uiState.update { it.copy(payMore = value) } + } + + fun updateTitle(text: String) { + _uiState.update { it.copy(title = text) } + } + + fun updateMessage(text: String) { + _uiState.update { it.copy(message = text) } + } + + fun goNext() { + _uiState.update { current -> + when (current.step) { + CreateDutchPayStep.PICK_USERS -> { + current.copy(step = CreateDutchPayStep.INPUT_AMOUNT) + } + + CreateDutchPayStep.INPUT_AMOUNT -> { + // 더치페이 요청 API 호출 + createDutchPayRequest() + current + } + + CreateDutchPayStep.COMPLETE -> current + } + } + } + + private fun createDutchPayRequest() { + val currentState = _uiState.value + + // 입력값 검증 + if (currentState.selectedUserIds.isEmpty() || + currentState.amountText.isBlank() || + currentState.title.isBlank() + ) { + _uiState.update { it.copy(errorMessage = "필수 입력값을 확인해주세요.") } + return + } + + val totalAmount = currentState.amountText.toLongOrNull() + if (totalAmount == null || totalAmount <= 0) { + _uiState.update { it.copy(errorMessage = "올바른 금액을 입력해주세요.") } + return + } + + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + viewModelScope.launch { + val request = DutchPayRequest( + userIds = currentState.selectedUserIds.toList(), + totalAmount = totalAmount, + title = currentState.title, + message = currentState.message, + payMore = currentState.payMore + ) + + createDutchPayRequestUseCase(request) + .onSuccess { + _uiState.update { + it.copy( + isLoading = false, + step = CreateDutchPayStep.COMPLETE, + errorMessage = null + ) + } + } + .onFailure { exception -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = exception.message ?: "더치페이 요청에 실패했습니다." + ) + } + } + } + } + + fun goPrev() { + _uiState.update { current -> + val prev = when (current.step) { + CreateDutchPayStep.PICK_USERS -> CreateDutchPayStep.PICK_USERS + CreateDutchPayStep.INPUT_AMOUNT -> CreateDutchPayStep.PICK_USERS + CreateDutchPayStep.COMPLETE -> CreateDutchPayStep.INPUT_AMOUNT + } + current.copy(step = prev) + } + } + + fun clearErrorMessage() { + _uiState.update { it.copy(errorMessage = null) } + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt index 7cc93d5..285e3b4 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.ssafy.tiggle.R +import com.ssafy.tiggle.core.utils.Formatter import com.ssafy.tiggle.domain.entity.piggybank.OpenAccount import com.ssafy.tiggle.presentation.ui.components.TiggleAllAgreeCheckboxItem import com.ssafy.tiggle.presentation.ui.components.TiggleButton @@ -310,7 +311,7 @@ private fun AmountOptionChip( contentPadding = PaddingValues(vertical = 10.dp) ) { Text( - text = "%,d원".format(amount), + text = Formatter.formatCurrency(amount.toLong()), style = AppTypography.bodyMedium, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt index 0dd2014..f3ab88c 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankScreen.kt @@ -23,8 +23,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -46,7 +44,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.ssafy.tiggle.R +import com.ssafy.tiggle.core.utils.Formatter import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout +import com.ssafy.tiggle.presentation.ui.components.TiggleSwitchRow import com.ssafy.tiggle.presentation.ui.theme.AppTypography import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue import com.ssafy.tiggle.presentation.ui.theme.TiggleGray @@ -58,6 +58,7 @@ fun PiggyBankScreen( modifier: Modifier = Modifier, onOpenAccountClick: () -> Unit = {}, onRegisterAccountClick: () -> Unit = {}, + onStartDutchPayClick: () -> Unit = {}, onBackClick: () -> Unit = {}, viewModel: PiggyBankViewModel = viewModel() ) { @@ -122,13 +123,13 @@ fun PiggyBankScreen( if (uiState.hasPiggyBank) { DutchButtonsRow( onStatus = {}, - onStart = {} + onStart = onStartDutchPayClick ) } Spacer(Modifier.height(18.dp)) // 스위치 섹션 - SettingSwitchRow( + TiggleSwitchRow( title = "잔돈 자동 기부", subtitle = "매일 잔정에 1,000원 미만 잔돈을 자동으로 기부합니다", checked = uiState.changeLeftoverDonate, @@ -142,7 +143,7 @@ fun PiggyBankScreen( ) // 스위치 섹션 2 - SettingSwitchRow( + TiggleSwitchRow( title = "목표 달성 자동 기부", subtitle = "더치페이 할 때 남는 자투리 금액을 기부할 수 있습니다", checked = uiState.achieveGoalDonate, @@ -202,42 +203,7 @@ private fun DottedActionCard( } } -@Composable -private fun SettingSwitchRow( - title: String, - subtitle: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 19.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(title, color = Color.Black, fontSize = 15.sp, style = AppTypography.bodyLarge) - Spacer(Modifier.height(4.dp)) - Text( - subtitle, - color = TiggleGrayText, - fontSize = 10.sp, - style = AppTypography.bodySmall - ) - } - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = TiggleBlue, - uncheckedThumbColor = Color.White, - uncheckedTrackColor = TiggleGray - ), - modifier = Modifier.size(10.dp, 5.dp) - ) - } -} +// moved: replaced by TiggleSwitchRow in components /** 점선 둥근 사각 보더 */ private fun Modifier.drawDottedRoundRect(cornerRadius: Dp) = this.then( @@ -308,7 +274,7 @@ private fun TodaySavingBanner(amount: Int, lastWeek: Int, rounded: Int) { ) Spacer(Modifier.height(6.dp)) Text( - "지난주 잔액 ${lastWeek.toMoney()} → ${rounded.toMoney()}원", + "지난주 잔액 ${Formatter.formatCurrency(lastWeek.toLong())} → ${Formatter.formatCurrency(rounded.toLong())}", color = Color(0xE6FFFFFF), style = AppTypography.bodySmall ) @@ -372,7 +338,7 @@ private fun AccountCard(bank: String, name: String, number: String, balance: Int Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("잔액", color = TiggleGrayText, style = AppTypography.bodySmall) Text( - "${balance.toMoney()}원", + Formatter.formatCurrency(balance.toLong()), color = Color.Black, fontSize = 22.sp, fontWeight = FontWeight.SemiBold @@ -410,7 +376,6 @@ private fun DutchButtonsRow(onStatus: () -> Unit, onStart: () -> Unit) { } } -private fun Int.toMoney(): String = "%,d".format(this) @Preview @Composable diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankViewModel.kt index d625fa5..5c9e393 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankViewModel.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/PiggyBankViewModel.kt @@ -9,8 +9,8 @@ import kotlinx.coroutines.flow.update class PiggyBankViewModel : ViewModel() { private val _uiState = MutableStateFlow( PiggyBankState( - hasPiggyBank = false, - hasLinkedAccount = false, + hasPiggyBank = true, + hasLinkedAccount = true, todaySaving = 847, lastWeekRemainder = 15847, lastWeekRounded = 15000, diff --git a/app/src/main/res/drawable/wallet.webp b/app/src/main/res/drawable/wallet.webp new file mode 100644 index 0000000..acfc227 Binary files /dev/null and b/app/src/main/res/drawable/wallet.webp differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e799352..ce489a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = retrofit-gson-converter = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofitGsonConverter" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } - +okhttp-urlconnection = { group = "com.squareup.okhttp3", name = "okhttp-urlconnection", version.ref = "okhttp" } # Coroutines kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }