diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 482163d..f79ce79 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,8 @@ android:name=".MainActivity" android:exported="true" android:label="@string/app_name" - android:theme="@style/Theme.Tiggle"> + android:theme="@style/Theme.Tiggle" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DonationApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DonationApiService.kt new file mode 100644 index 0000000..aa6a99f --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DonationApiService.kt @@ -0,0 +1,36 @@ +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.donation.DonationAccountDto +import com.ssafy.tiggle.data.model.donation.DonationHistoryDto +import com.ssafy.tiggle.data.model.donation.DonationRequestDto +import com.ssafy.tiggle.data.model.donation.DonationStatusDto +import com.ssafy.tiggle.data.model.donation.DonationSummaryDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface DonationApiService { + @GET("/api/donation/history") + suspend fun getDonationHistory(): Response>> + + @GET("/api/donation/status/summary") + suspend fun getDonationSummary(): Response> + + @GET("/api/donation/status") + suspend fun getMyDonationStatus(): Response> + + @GET("/api/donation/status/university") + suspend fun getUniversityDonationStatus(): Response> + + @GET("/api/donation/status/university/all") + suspend fun getAllUniversityDonationStatus(): Response> + + @GET("/api/donation") + suspend fun getDonationAccount(): Response> + + @POST("/api/donation") + suspend fun createDonation(@Body request: DonationRequestDto): Response> +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationAccountDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationAccountDto.kt new file mode 100644 index 0000000..d674a1a --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationAccountDto.kt @@ -0,0 +1,10 @@ +package com.ssafy.tiggle.data.model.donation + +import kotlinx.serialization.Serializable + +@Serializable +data class DonationAccountDto( + val accountName: String, + val accountNo: String, + val balance: String +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationHistoryDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationHistoryDto.kt new file mode 100644 index 0000000..d0c30f0 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationHistoryDto.kt @@ -0,0 +1,11 @@ +package com.ssafy.tiggle.data.model.donation + +import kotlinx.serialization.Serializable + +@Serializable +data class DonationHistoryDto( + val category: String, + val donatedAt: String, + val amount: Int, + val title: String +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationRequestDto.kt new file mode 100644 index 0000000..cb639d5 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationRequestDto.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.data.model.donation + +import kotlinx.serialization.Serializable + +@Serializable +data class DonationRequestDto( + val category: String, + val amount: Int +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationStatusDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationStatusDto.kt new file mode 100644 index 0000000..e5b28e4 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationStatusDto.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.data.model.donation + +data class DonationStatusDto( + val planetAmount: Int, + val peopleAmount: Int, + val prosperityAmount: Int +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationSummaryDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationSummaryDto.kt new file mode 100644 index 0000000..a1e8674 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationSummaryDto.kt @@ -0,0 +1,8 @@ +package com.ssafy.tiggle.data.model.donation + +data class DonationSummaryDto( + val totalAmount: Int, + val monthlyAmount: Int, + val categoryCnt: Int, + val universityRank: Int +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/DonationRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/DonationRepositoryImpl.kt new file mode 100644 index 0000000..f2e9d9c --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/DonationRepositoryImpl.kt @@ -0,0 +1,136 @@ +package com.ssafy.tiggle.data.repository + +import com.ssafy.tiggle.data.datasource.remote.DonationApiService +import com.ssafy.tiggle.data.model.donation.DonationRequestDto +import com.ssafy.tiggle.domain.entity.donation.DonationAccount +import com.ssafy.tiggle.domain.entity.donation.DonationCategory +import com.ssafy.tiggle.domain.entity.donation.DonationHistory +import com.ssafy.tiggle.domain.entity.donation.DonationRequest +import com.ssafy.tiggle.domain.entity.donation.DonationStatus +import com.ssafy.tiggle.domain.entity.donation.DonationStatusType +import com.ssafy.tiggle.domain.entity.donation.DonationSummary +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class DonationRepositoryImpl @Inject constructor( + private val donationApiService: DonationApiService +) : DonationRepository { + + override suspend fun getDonationHistory(): Result> { + return try { + val response = donationApiService.getDonationHistory() + if (response.isSuccessful && response.body()?.result == true) { + val donationHistoryList = response.body()?.data?.map { dto -> + // 디버깅을 위한 로그 추가 + println("DEBUG: API category = '${dto.category}'") + val category = DonationCategory.fromValue(dto.category) + println("DEBUG: Mapped category = ${category.name}") + + DonationHistory( + category = category, + donatedAt = dto.donatedAt, + amount = dto.amount, + title = dto.title + ) + } ?: emptyList() + Result.success(donationHistoryList) + } else { + Result.failure(Exception("Failed to fetch donation history")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getDonationSummary(): Result { + return try { + val response = donationApiService.getDonationSummary() + if (response.isSuccessful && response.body()?.result == true) { + val dto = response.body()?.data + dto?.let { + val summary = DonationSummary( + totalAmount = it.totalAmount, + monthlyAmount = it.monthlyAmount, + categoryCnt = it.categoryCnt, + universityRank = it.universityRank + ) + Result.success(summary) + } ?: Result.failure(Exception("Failed to fetch donation summary")) + } else { + Result.failure(Exception("Failed to fetch donation summary")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getDonationStatus(type: DonationStatusType): Result { + return try { + val response = when (type) { + DonationStatusType.MY_DONATION -> donationApiService.getMyDonationStatus() + DonationStatusType.UNIVERSITY -> donationApiService.getUniversityDonationStatus() + DonationStatusType.ALL_UNIVERSITY -> donationApiService.getAllUniversityDonationStatus() + } + + if (response.isSuccessful && response.body()?.result == true) { + val dto = response.body()?.data + dto?.let { + val status = DonationStatus( + planetAmount = it.planetAmount, + peopleAmount = it.peopleAmount, + prosperityAmount = it.prosperityAmount + ) + Result.success(status) + } ?: Result.failure(Exception("Failed to fetch donation status")) + } else { + Result.failure(Exception("Failed to fetch donation status")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getDonationAccount(): Result { + return try { + val response = donationApiService.getDonationAccount() + + if (response.isSuccessful && response.body()?.result == true) { + val dto = response.body()?.data + if (dto != null) { + Result.success( + DonationAccount( + accountName = dto.accountName, + accountNo = dto.accountNo, + balance = dto.balance + ) + ) + } else { + Result.failure(Exception("계좌 정보가 없습니다.")) + } + } else { + Result.failure(Exception(response.body()?.message ?: "알 수 없는 오류가 발생했습니다.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun createDonation(request: DonationRequest): Result { + return try { + val dto = DonationRequestDto( + category = request.category.name, + amount = request.amount + ) + + val response = donationApiService.createDonation(dto) + + if (response.isSuccessful && response.body()?.result == true) { + Result.success(Unit) + } else { + Result.failure(Exception(response.body()?.message ?: "기부 처리 중 오류가 발생했습니다.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} 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 45bbf20..c025fb6 100644 --- a/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt +++ b/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt @@ -5,6 +5,7 @@ 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.DonationApiService import com.ssafy.tiggle.data.datasource.remote.DutchPayApiService import com.ssafy.tiggle.data.datasource.remote.FcmApiService import com.ssafy.tiggle.data.datasource.remote.PiggyBankApiService @@ -142,4 +143,9 @@ object NetworkModule { @Singleton fun provideDutchPayApiService(retrofit: Retrofit): DutchPayApiService = retrofit.create(DutchPayApiService::class.java) + + @Provides + @Singleton + fun provideDonationApiService(retrofit: Retrofit): DonationApiService = + retrofit.create(DonationApiService::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 04aa6f1..2ef73f7 100644 --- a/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt +++ b/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt @@ -1,12 +1,14 @@ package com.ssafy.tiggle.di import com.ssafy.tiggle.data.repository.AuthRepositoryImpl +import com.ssafy.tiggle.data.repository.DonationRepositoryImpl 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.DonationRepository import com.ssafy.tiggle.domain.repository.DutchPayRepository import com.ssafy.tiggle.domain.repository.FcmRepository import com.ssafy.tiggle.domain.repository.PiggyBankRepository @@ -61,4 +63,10 @@ abstract class RepositoryModule { abstract fun bindDutchPayRepository( dutchPayRepositoryImpl: DutchPayRepositoryImpl ): DutchPayRepository + + @Binds + @Singleton + abstract fun bindDonationRepository( + donationRepositoryImpl: DonationRepositoryImpl + ): DonationRepository } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationAccount.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationAccount.kt new file mode 100644 index 0000000..3cc0e7e --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationAccount.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.domain.entity.donation + +data class DonationAccount( + val accountName: String, + val accountNo: String, + val balance: String +) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationHistory.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationHistory.kt new file mode 100644 index 0000000..9ca5a3e --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationHistory.kt @@ -0,0 +1,20 @@ +package com.ssafy.tiggle.domain.entity.donation + +data class DonationHistory( + val category: DonationCategory, + val donatedAt: String, + val amount: Int, + val title: String +) + +enum class DonationCategory(val value: String, val iconResName: String) { + PLANET("Planet", "planet"), + PEOPLE("People", "people"), + PROSPERITY("Prosperity", "prosperity"); + + companion object { + fun fromValue(value: String): DonationCategory { + return values().find { it.value == value } ?: PLANET + } + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationRequest.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationRequest.kt new file mode 100644 index 0000000..84cd916 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationRequest.kt @@ -0,0 +1,6 @@ +package com.ssafy.tiggle.domain.entity.donation + +data class DonationRequest( + val category: DonationCategory, + val amount: Int +) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationStatus.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationStatus.kt new file mode 100644 index 0000000..dab1418 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationStatus.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.entity.donation + +data class DonationStatus( + val planetAmount: Int, + val peopleAmount: Int, + val prosperityAmount: Int +) + +enum class DonationStatusType { + MY_DONATION, + UNIVERSITY, + ALL_UNIVERSITY +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationSummary.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationSummary.kt new file mode 100644 index 0000000..c2b7106 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationSummary.kt @@ -0,0 +1,8 @@ +package com.ssafy.tiggle.domain.entity.donation + +data class DonationSummary( + val totalAmount: Int, + val monthlyAmount: Int, + val categoryCnt: Int, + val universityRank: Int +) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/DonationRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/DonationRepository.kt new file mode 100644 index 0000000..51001d7 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/DonationRepository.kt @@ -0,0 +1,16 @@ +package com.ssafy.tiggle.domain.repository + +import com.ssafy.tiggle.domain.entity.donation.DonationAccount +import com.ssafy.tiggle.domain.entity.donation.DonationHistory +import com.ssafy.tiggle.domain.entity.donation.DonationRequest +import com.ssafy.tiggle.domain.entity.donation.DonationStatus +import com.ssafy.tiggle.domain.entity.donation.DonationStatusType +import com.ssafy.tiggle.domain.entity.donation.DonationSummary + +interface DonationRepository { + suspend fun getDonationHistory(): Result> + suspend fun getDonationSummary(): Result + suspend fun getDonationStatus(type: DonationStatusType): Result + suspend fun getDonationAccount(): Result + suspend fun createDonation(request: DonationRequest): Result +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/CreateDonationUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/CreateDonationUseCase.kt new file mode 100644 index 0000000..4efa1d6 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/CreateDonationUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationRequest +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class CreateDonationUseCase @Inject constructor( + private val donationRepository: DonationRepository +) { + suspend operator fun invoke(request: DonationRequest): Result { + return donationRepository.createDonation(request) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationAccountUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationAccountUseCase.kt new file mode 100644 index 0000000..b38d4cd --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationAccountUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationAccount +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class GetDonationAccountUseCase @Inject constructor( + private val donationRepository: DonationRepository +) { + suspend operator fun invoke(): Result { + return donationRepository.getDonationAccount() + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationHistoryUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationHistoryUseCase.kt new file mode 100644 index 0000000..3c0ee24 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationHistoryUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationHistory +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class GetDonationHistoryUseCase @Inject constructor( + private val donationRepository: DonationRepository +) { + suspend operator fun invoke(): Result> { + return donationRepository.getDonationHistory() + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationStatusUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationStatusUseCase.kt new file mode 100644 index 0000000..18b2b22 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationStatusUseCase.kt @@ -0,0 +1,14 @@ +package com.ssafy.tiggle.domain.usecase.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationStatus +import com.ssafy.tiggle.domain.entity.donation.DonationStatusType +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class GetDonationStatusUseCase @Inject constructor( + private val donationRepository: DonationRepository +) { + suspend operator fun invoke(type: DonationStatusType): Result { + return donationRepository.getDonationStatus(type) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationSummaryUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationSummaryUseCase.kt new file mode 100644 index 0000000..9b27608 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDonationSummaryUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationSummary +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class GetDonationSummaryUseCase @Inject constructor( + private val donationRepository: DonationRepository +) { + suspend operator fun invoke(): Result { + return donationRepository.getDonationSummary() + } +} 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 be28ca5..0ffd464 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,7 +14,10 @@ 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.donation.DonationHistoryScreen +import com.ssafy.tiggle.presentation.ui.donation.DonationStatusScreen import com.ssafy.tiggle.presentation.ui.dutchpay.CreateDutchPayScreen +import com.ssafy.tiggle.presentation.ui.growth.GrowthScreen import com.ssafy.tiggle.presentation.ui.piggybank.OpenAccountScreen import com.ssafy.tiggle.presentation.ui.piggybank.PiggyBankScreen import com.ssafy.tiggle.presentation.ui.piggybank.RegisterAccountScreen @@ -68,7 +71,17 @@ fun NavigationGraph() { } is BottomScreen.Growth -> NavEntry(key) { - GrowthScreen() + GrowthScreen( + onDonationHistoryClick = { + navBackStack.add(Screen.DonationHistory) + }, + onDonationStatusClick = { + navBackStack.add(Screen.DonationStatus) + }, + onDonationRankingClick = { + // TODO: 기부 랭킹 화면 구현 시 추가 + } + ) } is BottomScreen.Shorts -> NavEntry(key) { @@ -117,6 +130,18 @@ fun NavigationGraph() { ) } + is Screen.DonationHistory -> NavEntry(key) { + DonationHistoryScreen( + onBackClick = { navBackStack.removeLastOrNull() } + ) + } + + is Screen.DonationStatus -> NavEntry(key) { + DonationStatusScreen( + onBackClick = { navBackStack.removeLastOrNull() } + ) + } + else -> throw IllegalArgumentException("Unknown route: $key") } @@ -130,10 +155,6 @@ fun NavigationGraph() { * 메인 화면의 바텀 네비게이션 */ // 임시 화면들 -@Composable -private fun GrowthScreen() { - Text("성장") -} @Composable private fun ShortsScreen() { 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 ab72fac..b456fbb 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 @@ -35,4 +35,10 @@ sealed interface Screen : NavKey { @Serializable object CreateDutchPay : Screen + + @Serializable + object DonationHistory : Screen + + @Serializable + object DonationStatus : Screen } 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 537e6f5..e791914 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 @@ -7,7 +7,10 @@ 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.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -28,51 +31,68 @@ fun TiggleScreenLayout( onBackClick: () -> Unit = {}, showLogo: Boolean = false, bottomButton: @Composable (() -> Unit)? = null, + enableScroll: Boolean = true, content: @Composable () -> Unit ) { - Box( + val scrollState = rememberScrollState() + + Column( modifier = Modifier .fillMaxSize() .background(Color.White) + .imePadding() // imePadding이 키보드 높이만큼 패딩을 자동으로 추가해줍니다. ) { - Column( - modifier = Modifier.fillMaxSize() - ) { - // 헤더 - if (showBackButton || title != null) { - TiggleHeader( - title = title, - showBackButton = showBackButton, - onBackClick = onBackClick - ) - } + // 헤더 + if (showBackButton || title != null) { + TiggleHeader( + title = title, + showBackButton = showBackButton, + onBackClick = onBackClick + ) + } - // 로고 (선택적) - if (showLogo) { - Spacer(modifier = Modifier.height(32.dp)) - TiggleLogo() + // 메인 콘텐츠 + if (enableScroll) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 32.dp) + .verticalScroll(scrollState) + ) { + if (showLogo) { + TiggleLogo() + } + content() } - - // 메인 콘텐츠 + } else { + // LazyColumn 등을 사용하는 화면을 위한 레이아웃 Box( modifier = Modifier .fillMaxWidth() .weight(1f) .padding(horizontal = 32.dp) ) { - content() + if (showLogo) { + Column { + TiggleLogo() + content() + } + } else { + content() + } } + } - // 하단 버튼 (선택적) - bottomButton?.let { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - .padding(bottom = 32.dp) - ) { - it() - } + // 하단 버튼 (선택적) + bottomButton?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .padding(bottom = 32.dp) + ) { + it() } } } @@ -100,7 +120,7 @@ private fun TiggleScreenLayoutPreview() { ) 23 Spacer(modifier = Modifier.height(16.dp)) - + Text( text = "여기에 다양한 콘텐츠가 들어갑니다.", fontSize = 14.sp, 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 7295920..92132f9 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 @@ -8,6 +8,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.ui.text.input.ImeAction import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -28,6 +29,8 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayLight import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText @@ -63,7 +66,14 @@ fun TiggleTextField( OutlinedTextField( value = value, onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + // 포커스가 변경될 때 키보드 관련 처리 + if (focusState.isFocused) { + // 포커스가 되었을 때의 처리 + } + }, placeholder = { Text( text = placeholder, @@ -95,7 +105,8 @@ fun TiggleTextField( } } else null, keyboardOptions = KeyboardOptions( - keyboardType = keyboardType + keyboardType = keyboardType, + imeAction = if (maxLines == 1) ImeAction.Next else ImeAction.Done ), singleLine = maxLines == 1, maxLines = maxLines, diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryScreen.kt new file mode 100644 index 0000000..1a2fb54 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryScreen.kt @@ -0,0 +1,308 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import android.os.Build +import androidx.annotation.RequiresApi +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.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.layout.width +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.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.donation.DonationCategory +import com.ssafy.tiggle.domain.entity.donation.DonationHistory +import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout +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 com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText + +@Composable +fun DonationHistoryScreen( + onBackClick: () -> Unit = {}, + viewModel: DonationHistoryViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadDonationHistory() + } + + TiggleScreenLayout( + title = "나의 기부 기록", + showBackButton = true, + onBackClick = onBackClick, + enableScroll = false + ) { + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = TiggleBlue) + } + } + + !uiState.errorMessage.isNullOrEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = uiState.errorMessage ?: "알 수 없는 오류가 발생했습니다.", + color = Color.Red, + textAlign = TextAlign.Center + ) + } + } + + else -> { + DonationHistoryContent( + donationHistoryList = uiState.donationHistoryList + ) + } + } + } +} + +@Composable +private fun DonationHistoryContent( + donationHistoryList: List +) { + Column( + modifier = Modifier.fillMaxSize() + ) { + if (donationHistoryList.isEmpty()) { + // 기부 기록이 없을 때 + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "아직 기부 기록이 없어요", + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = TiggleGrayText, + style = AppTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "첫 번째 기부를 시작해보세요!", + fontSize = 14.sp, + color = TiggleGrayText, + style = AppTypography.bodyMedium + ) + } + } + } else { + // 기부 기록이 있을 때 + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(donationHistoryList) { donation -> + DonationHistoryItem(donation = donation) + } + } + } + + // Footer 카드는 항상 맨 아래에 표시 + DonationFooterCard() + } +} + +@Composable +private fun DonationHistoryItem( + donation: DonationHistory +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 카테고리 아이콘 + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(TiggleGrayLight), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = getCategoryIconRes(donation.category)), + contentDescription = donation.category.value, + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // 기부 정보 + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = donation.title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black, + style = AppTypography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = donation.donatedAt, + fontSize = 12.sp, + color = TiggleGrayText, + style = AppTypography.bodySmall + ) + } + + // 기부 금액 + Text( + text = Formatter.formatCurrency(donation.amount.toLong()), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + style = AppTypography.bodyLarge + ) + } +} + +@Composable +private fun DonationFooterCard() { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = "💡 ", + fontSize = 20.sp + ) + + Text( + text = "기부 방식", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = TiggleBlue + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "티끌은 모든 과정에서 투명성과 신뢰를 " + + "최우선 가치로 삼습니다.\n" + + "기부금의 사용을 투명하게 공개하고 정해진 절차에 따라 집행하여, " + + "누구나 안심하고 참여할 수 있는 책임 있는 " + + "기부 시스템을 운영합니다.", + fontSize = 12.sp, + color = TiggleGrayText, + lineHeight = 16.sp + ) + } + } +} + +private fun getCategoryIconRes(category: DonationCategory): Int { + return when (category) { + DonationCategory.PLANET -> R.drawable.planet + DonationCategory.PEOPLE -> R.drawable.people + DonationCategory.PROSPERITY -> R.drawable.prosperity + } +} + + +@RequiresApi(Build.VERSION_CODES.O) +@Preview(showBackground = true) +@Composable +private fun DonationHistoryScreenPreview() { + // 샘플 데이터 생성 + val sampleHistoryList = listOf( + DonationHistory( + category = DonationCategory.PLANET, + donatedAt = "2024-08-18T19:38:00", + amount = 3450, + title = "Planet" + ), + DonationHistory( + category = DonationCategory.PEOPLE, + donatedAt = "2024-07-18T14:20:00", + amount = 780, + title = "People" + ), + DonationHistory( + category = DonationCategory.PEOPLE, + donatedAt = "2024-07-18T11:15:00", + amount = 780, + title = "People" + ), + DonationHistory( + category = DonationCategory.PLANET, + donatedAt = "2024-07-18T09:30:00", + amount = 780, + title = "Planet" + ) + ) + + DonationHistoryContent(donationHistoryList = sampleHistoryList) +} + +@RequiresApi(Build.VERSION_CODES.O) +@Preview(showBackground = true) +@Composable +private fun DonationHistoryEmptyPreview() { + // 빈 리스트로 미리보기 + DonationHistoryContent(donationHistoryList = emptyList()) +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryUiState.kt new file mode 100644 index 0000000..0aee6c0 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryUiState.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationHistory + +data class DonationHistoryUiState( + val isLoading: Boolean = false, + val donationHistoryList: List = emptyList(), + val errorMessage: String? = null +) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryViewModel.kt new file mode 100644 index 0000000..be1db9b --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationHistoryViewModel.kt @@ -0,0 +1,40 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ssafy.tiggle.domain.usecase.donation.GetDonationHistoryUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DonationHistoryViewModel @Inject constructor( + private val getDonationHistoryUseCase: GetDonationHistoryUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(DonationHistoryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadDonationHistory() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + getDonationHistoryUseCase() + .onSuccess { historyList -> + _uiState.value = _uiState.value.copy( + isLoading = false, + donationHistoryList = historyList + ) + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." + ) + } + } + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModal.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModal.kt new file mode 100644 index 0000000..99b533e --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModal.kt @@ -0,0 +1,267 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import androidx.compose.foundation.Image +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.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.donation.DonationCategory +import com.ssafy.tiggle.presentation.ui.components.TiggleButton +import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayLight +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DonationModal( + onDismiss: () -> Unit, + onSuccess: () -> Unit, + viewModel: DonationModalViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val sheetState = rememberModalBottomSheetState() + + // 성공 시 모달 닫기 + LaunchedEffect(uiState.isSuccess) { + if (uiState.isSuccess) { + onSuccess() + onDismiss() + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + // 헤더 + Text( + text = "기부하기", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + + Text( + text = "기부 금액과 분야를 설정하여 나의 티끌들을 전해보세요", + fontSize = 14.sp, + color = TiggleGrayText, + modifier = Modifier.padding(top = 4.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 테마 선택 + Text( + text = "기부 테마", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + DonationCategoryButton( + category = DonationCategory.PLANET, + isSelected = uiState.selectedCategory == DonationCategory.PLANET, + onClick = { viewModel.onCategorySelected(DonationCategory.PLANET) }, + modifier = Modifier.weight(1f) + ) + + DonationCategoryButton( + category = DonationCategory.PEOPLE, + isSelected = uiState.selectedCategory == DonationCategory.PEOPLE, + onClick = { viewModel.onCategorySelected(DonationCategory.PEOPLE) }, + modifier = Modifier.weight(1f) + ) + + DonationCategoryButton( + category = DonationCategory.PROSPERITY, + isSelected = uiState.selectedCategory == DonationCategory.PROSPERITY, + onClick = { viewModel.onCategorySelected(DonationCategory.PROSPERITY) }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 기부 금액 입력 + Text( + text = "기부 금액", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.amount, + onValueChange = { viewModel.onAmountChanged(it) }, + placeholder = { + Text( + text = "기부 금액을 입력해주세요.", + color = TiggleGrayText + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + suffix = { + Text( + text = "원", + color = TiggleGrayText + ) + } + ) + + // 잔액 표시 + uiState.account?.let { account -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "천사 꿀꿀이 잔액: ${Formatter.formatCurrency(account.balance.toLong())}", + fontSize = 12.sp, + color = TiggleBlue + ) + } + + // 오류 메시지 + uiState.errorMessage?.let { errorMessage -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage, + fontSize = 12.sp, + color = Color.Red, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 확인 버튼 + TiggleButton( + onClick = { viewModel.createDonation() }, + text = if (uiState.isLoading) "처리 중..." else "확인", + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + enabled = !uiState.isLoading && uiState.amount.isNotEmpty() + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun DonationCategoryButton( + category: DonationCategory, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .height(80.dp) + .clickable { onClick() }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) Color.White else TiggleGrayLight + ), + border = if (isSelected) { + androidx.compose.foundation.BorderStroke(2.dp, TiggleBlue) + } else null + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // 카테고리 아이콘 + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(TiggleGrayLight), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = getCategoryIconRes(category)), + contentDescription = category.value, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 카테고리 이름 + Text( + text = getCategoryDisplayName(category), + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) TiggleBlue else TiggleGrayText + ) + } + } +} + +private fun getCategoryIconRes(category: DonationCategory): Int { + return when (category) { + DonationCategory.PLANET -> R.drawable.planet + DonationCategory.PEOPLE -> R.drawable.people + DonationCategory.PROSPERITY -> R.drawable.prosperity + } +} + +private fun getCategoryDisplayName(category: DonationCategory): String { + return when (category) { + DonationCategory.PLANET -> "Planet" + DonationCategory.PEOPLE -> "People" + DonationCategory.PROSPERITY -> "Prosperity" + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModalUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModalUiState.kt new file mode 100644 index 0000000..8cbda2d --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModalUiState.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationAccount +import com.ssafy.tiggle.domain.entity.donation.DonationCategory + +data class DonationModalUiState( + val isLoading: Boolean = false, + val account: DonationAccount? = null, + val selectedCategory: DonationCategory = DonationCategory.PLANET, + val amount: String = "", + val errorMessage: String? = null, + val isSuccess: Boolean = false +) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModalViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModalViewModel.kt new file mode 100644 index 0000000..14ba95f --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationModalViewModel.kt @@ -0,0 +1,104 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ssafy.tiggle.domain.entity.donation.DonationCategory +import com.ssafy.tiggle.domain.entity.donation.DonationRequest +import com.ssafy.tiggle.domain.usecase.donation.CreateDonationUseCase +import com.ssafy.tiggle.domain.usecase.donation.GetDonationAccountUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DonationModalViewModel @Inject constructor( + private val getDonationAccountUseCase: GetDonationAccountUseCase, + private val createDonationUseCase: CreateDonationUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(DonationModalUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAccount() + } + + private fun loadAccount() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val result = getDonationAccountUseCase() + if (result.isSuccess) { + _uiState.value = _uiState.value.copy( + isLoading = false, + account = result.getOrNull() + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = result.exceptionOrNull()?.message ?: "계좌 정보를 불러올 수 없습니다." + ) + } + } + } + + fun onCategorySelected(category: DonationCategory) { + _uiState.value = _uiState.value.copy(selectedCategory = category) + } + + fun onAmountChanged(amount: String) { + // 숫자만 입력 가능하도록 필터링 + val filteredAmount = amount.filter { it.isDigit() } + _uiState.value = _uiState.value.copy(amount = filteredAmount) + } + + fun createDonation() { + val amount = _uiState.value.amount.toIntOrNull() + if (amount == null || amount <= 0) { + _uiState.value = _uiState.value.copy(errorMessage = "올바른 금액을 입력해주세요.") + return + } + + val account = _uiState.value.account + if (account == null) { + _uiState.value = _uiState.value.copy(errorMessage = "계좌 정보를 불러올 수 없습니다.") + return + } + + val accountBalance = account.balance.toIntOrNull() ?: 0 + if (amount > accountBalance) { + _uiState.value = _uiState.value.copy(errorMessage = "잔액이 부족합니다.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val request = DonationRequest( + category = _uiState.value.selectedCategory, + amount = amount + ) + + val result = createDonationUseCase(request) + if (result.isSuccess) { + _uiState.value = _uiState.value.copy( + isLoading = false, + isSuccess = true + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = result.exceptionOrNull()?.message ?: "기부 처리 중 오류가 발생했습니다." + ) + } + } + } + + fun resetState() { + _uiState.value = DonationModalUiState() + loadAccount() + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusScreen.kt new file mode 100644 index 0000000..6d09dca --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusScreen.kt @@ -0,0 +1,940 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.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.donation.DonationCategory +import com.ssafy.tiggle.domain.entity.donation.DonationStatus +import com.ssafy.tiggle.domain.entity.donation.DonationStatusType +import com.ssafy.tiggle.domain.entity.donation.DonationSummary +import com.ssafy.tiggle.presentation.ui.components.TiggleButton +import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout +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 DonationStatusScreen( + onBackClick: () -> Unit = {}, + viewModel: DonationStatusViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var showDonationModal by remember { mutableStateOf(false) } + + TiggleScreenLayout( + title = "기부", + showBackButton = true, + onBackClick = onBackClick, + enableScroll = false + ) { + when { + uiState.isLoading && uiState.donationSummary == null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = TiggleBlue) + } + } + + else -> { + DonationStatusContent( + donationSummary = uiState.donationSummary, + donationStatus = uiState.donationStatus, + currentStatusType = uiState.currentStatusType, + onStatusTypeChanged = viewModel::onStatusTypeChanged, + onDonateClick = { showDonationModal = true }, + isLoading = uiState.isLoading, + errorMessage = uiState.errorMessage + ) + } + } + } + + // 기부하기 모달 + if (showDonationModal) { + DonationModal( + onDismiss = { showDonationModal = false }, + onSuccess = { + // 기부 성공 시 데이터 새로고침 + viewModel.loadDonationSummary() + viewModel.loadDonationStatus(uiState.currentStatusType) + } + ) + } +} + +@Composable +private fun DonationStatusContent( + donationSummary: DonationSummary?, + donationStatus: DonationStatus?, + currentStatusType: DonationStatusType, + onStatusTypeChanged: (DonationStatusType) -> Unit, + onDonateClick: () -> Unit, + isLoading: Boolean, + errorMessage: String? = null +) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + // 헤더 메시지 + item { + Text( + text = "작은 티끌이 만든 변화를 확인해보세요", + fontSize = 14.sp, + color = TiggleGrayText, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + item { Spacer(modifier = Modifier.height(8.dp)) } + + // 기부 요약 카드 + item { + if (donationSummary != null) { + SummaryCard(summary = donationSummary) + } else if (!errorMessage.isNullOrEmpty()) { + SummaryErrorCard(errorMessage = errorMessage) + } else { + // 로딩 중이거나 데이터가 없는 경우 기본 카드 표시 + SummaryLoadingCard() + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + + // 탭 버튼들 + item { + StatusTypeButtons( + currentType = currentStatusType, + onTypeChanged = onStatusTypeChanged + ) + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + + // 기부 현황 타이틀 + item { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 파란색 세로 라인 + Box( + modifier = Modifier + .width(4.dp) + .height(20.dp) + .background( + color = TiggleBlue, + shape = RoundedCornerShape(2.dp) + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = getStatusTitle(currentStatusType), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + + // 기부 현황 리스트 + item { + if (isLoading) { + DonationStatusLoadingCard() + } else if (donationStatus != null) { + DonationStatusList(status = donationStatus) + } else if (!errorMessage.isNullOrEmpty()) { + DonationStatusErrorCard(errorMessage = errorMessage) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + + // 하단 콘텐츠 (탭별로 다름) + item { + if (donationStatus != null && !isLoading) { + val totalAmount = + donationStatus.planetAmount + donationStatus.peopleAmount + donationStatus.prosperityAmount + + when (currentStatusType) { + DonationStatusType.ALL_UNIVERSITY -> { + TotalAmountCard( + title = "전체 학교 총 기부액", + totalAmount = totalAmount + ) + } + + DonationStatusType.UNIVERSITY -> { + TotalAmountCard( + title = "우리 학교 총 기부액", + totalAmount = totalAmount + ) + } + + DonationStatusType.MY_DONATION -> { + TiggleButton( + onClick = onDonateClick, + text = "기부하기", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(48.dp), + ) + } + } + } + } + + // 하단 여백 추가 + item { Spacer(modifier = Modifier.height(32.dp)) } + } +} + +@Composable +private fun SummaryCard(summary: DonationSummary) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "${summary.totalAmount}원", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + + Text( + text = "내 총 기부 금액", + fontSize = 14.sp, + color = TiggleGrayText, + modifier = Modifier.padding(top = 4.dp) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + SummaryItem( + value = "${summary.monthlyAmount}원", + label = "이번 달" + ) + SummaryItem( + value = "${summary.categoryCnt}개", + label = "참여 분야" + ) + SummaryItem( + value = "${summary.universityRank}위", + label = "학교 순위" + ) + } + } + } +} + +@Composable +private fun SummaryErrorCard(errorMessage: String) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "⚠️", + fontSize = 24.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "기부 요약 정보를 불러올 수 없습니다", + fontSize = 14.sp, + color = TiggleGrayText, + textAlign = TextAlign.Center + ) + Text( + text = "다시 시도해주세요", + fontSize = 12.sp, + color = TiggleGrayText, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} + +@Composable +private fun SummaryLoadingCard() { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + color = TiggleBlue, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "기부 요약 정보를 불러오는 중...", + fontSize = 14.sp, + color = TiggleGrayText, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun DonationStatusErrorCard(errorMessage: String) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "⚠️", + fontSize = 32.sp + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "기부 현황을 불러올 수 없습니다", + fontSize = 16.sp, + color = Color.Black, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "네트워크 연결을 확인하고\n다시 시도해주세요", + fontSize = 14.sp, + color = TiggleGrayText, + textAlign = TextAlign.Center, + lineHeight = 18.sp + ) + } + } + } +} + +@Composable +private fun SummaryItem(value: String, label: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Text( + text = label, + fontSize = 12.sp, + color = TiggleGrayText, + modifier = Modifier.padding(top = 4.dp) + ) + } +} + +@Composable +private fun StatusTypeButtons( + currentType: DonationStatusType, + onTypeChanged: (DonationStatusType) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(25.dp), + colors = CardDefaults.cardColors(containerColor = TiggleGrayLight), + elevation = CardDefaults.cardElevation(0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + StatusTab( + text = "나의 기부", + isSelected = currentType == DonationStatusType.MY_DONATION, + onClick = { onTypeChanged(DonationStatusType.MY_DONATION) }, + modifier = Modifier.weight(1f) + ) + StatusTab( + text = "우리 학교", + isSelected = currentType == DonationStatusType.UNIVERSITY, + onClick = { onTypeChanged(DonationStatusType.UNIVERSITY) }, + modifier = Modifier.weight(1f) + ) + StatusTab( + text = "전체 학교", + isSelected = currentType == DonationStatusType.ALL_UNIVERSITY, + onClick = { onTypeChanged(DonationStatusType.ALL_UNIVERSITY) }, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun StatusTab( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(44.dp) + .clip(RoundedCornerShape(21.dp)) + .background( + if (isSelected) TiggleBlue else Color.Transparent + ) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = text, + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) Color.White else TiggleGrayText, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun DonationStatusList(status: DonationStatus) { + // 총 금액 계산 (진행률 계산을 위해) + val totalAmount = status.planetAmount + status.peopleAmount + status.prosperityAmount + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + DonationProgressItem( + category = DonationCategory.PLANET, + amount = status.planetAmount, + totalAmount = totalAmount, + delayMillis = 0 + ) + DonationProgressItem( + category = DonationCategory.PEOPLE, + amount = status.peopleAmount, + totalAmount = totalAmount, + delayMillis = 200 + ) + DonationProgressItem( + category = DonationCategory.PROSPERITY, + amount = status.prosperityAmount, + totalAmount = totalAmount, + delayMillis = 400 + ) + } + } +} + +@Composable +private fun DonationStatusLoadingCard() { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(280.dp), // 실제 카드와 비슷한 높이 + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + color = TiggleBlue, + modifier = Modifier.size(40.dp), + strokeWidth = 4.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "기부 현황을 불러오는 중...", + fontSize = 14.sp, + color = TiggleGrayText + ) + } + } + } +} + +@Composable +private fun DonationProgressItem( + category: DonationCategory, + amount: Int, + totalAmount: Int, + delayMillis: Int +) { + var isVisible by remember { mutableStateOf(false) } + + // 진행률 계산 (최대 1.0) + val progress = if (totalAmount > 0) amount.toFloat() / totalAmount.toFloat() else 0f + + // 애니메이션된 진행률 + val animatedProgress by animateFloatAsState( + targetValue = if (isVisible) progress else 0f, + animationSpec = tween( + durationMillis = 1200, + delayMillis = delayMillis, + easing = FastOutSlowInEasing + ), + label = "progress" + ) + + LaunchedEffect(Unit) { + isVisible = true + } + + Column { + // 카테고리 아이콘과 이름 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // 카테고리 아이콘 + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(TiggleGrayLight), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = getCategoryIconRes(category)), + contentDescription = category.value, + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // 카테고리 이름 + Text( + text = getCategoryDisplayName(category), + fontSize = 16.sp, + color = TiggleGrayText, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 프로그레스 바 + Box( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .border( + width = 1.dp, + color = Color.Gray.copy(alpha = 0.2f), + shape = RoundedCornerShape(20.dp) + ) + .background(Color.Gray.copy(alpha = 0.1f)) + ) { + // 진행률 바 + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(animatedProgress) + .clip(RoundedCornerShape(20.dp)) + .background(getCategoryColor(category)) + ) + + // 금액 텍스트 (중앙에 위치) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = Formatter.formatCurrency(amount.toLong()), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } + } +} + +@Composable +private fun TotalAmountCard(title: String, totalAmount: Int) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = TiggleGrayLight) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + Text( + text = Formatter.formatCurrency(totalAmount.toLong()), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } + } +} + +private fun getCategoryIconRes(category: DonationCategory): Int { + return when (category) { + DonationCategory.PLANET -> R.drawable.planet + DonationCategory.PEOPLE -> R.drawable.people + DonationCategory.PROSPERITY -> R.drawable.prosperity + } +} + +private fun getCategoryDisplayName(category: DonationCategory): String { + return when (category) { + DonationCategory.PLANET -> "Planet" + DonationCategory.PEOPLE -> "People" + DonationCategory.PROSPERITY -> "Prosperity" + } +} + +private fun getCategoryColor(category: DonationCategory): Color { + return when (category) { + DonationCategory.PLANET -> Color(0xFF4CAF50) // 초록 + DonationCategory.PEOPLE -> Color(0xFFFF9800) // 주황 + DonationCategory.PROSPERITY -> Color(0xFF9C27B0) // 보라 + } +} + +private fun getStatusTitle(type: DonationStatusType): String { + return when (type) { + DonationStatusType.MY_DONATION -> "나의 기부 현황" + DonationStatusType.UNIVERSITY -> "우리 학교 기부 현황" + DonationStatusType.ALL_UNIVERSITY -> "전체 학교 기부 현황" + } +} + +// 전체 학교 기부 현황 (기본) +@Preview(showBackground = true, name = "전체 학교 기부 현황") +@Composable +private fun DonationStatusAllUniversityPreview() { + val sampleSummary = DonationSummary( + totalAmount = 847, + monthlyAmount = 330, + categoryCnt = 3, + universityRank = 53 + ) + + val sampleStatus = DonationStatus( + planetAmount = 1904400, + peopleAmount = 238394, + prosperityAmount = 530926 + ) + + DonationStatusContent( + donationSummary = sampleSummary, + donationStatus = sampleStatus, + currentStatusType = DonationStatusType.ALL_UNIVERSITY, + onStatusTypeChanged = {}, + onDonateClick = {}, + isLoading = false, + errorMessage = null + ) +} + +// 우리 학교 기부 현황 +@Preview(showBackground = true, name = "우리 학교 기부 현황") +@Composable +private fun DonationStatusUniversityPreview() { + val sampleSummary = DonationSummary( + totalAmount = 2450, + monthlyAmount = 780, + categoryCnt = 2, + universityRank = 12 + ) + + val sampleStatus = DonationStatus( + planetAmount = 450000, + peopleAmount = 120000, + prosperityAmount = 280000 + ) + + DonationStatusContent( + donationSummary = sampleSummary, + donationStatus = sampleStatus, + currentStatusType = DonationStatusType.UNIVERSITY, + onStatusTypeChanged = {}, + onDonateClick = {}, + isLoading = false, + errorMessage = null + ) +} + +// 나의 기부 현황 +@Preview(showBackground = true, name = "나의 기부 현황") +@Composable +private fun DonationStatusMyDonationPreview() { + val sampleSummary = DonationSummary( + totalAmount = 847, + monthlyAmount = 330, + categoryCnt = 3, + universityRank = 53 + ) + + val sampleStatus = DonationStatus( + planetAmount = 500, + peopleAmount = 200, + prosperityAmount = 147 + ) + + DonationStatusContent( + donationSummary = sampleSummary, + donationStatus = sampleStatus, + currentStatusType = DonationStatusType.MY_DONATION, + onStatusTypeChanged = {}, + onDonateClick = {}, + isLoading = false + ) +} + +// 로딩 상태 +@Preview(showBackground = true, name = "로딩 상태") +@Composable +private fun DonationStatusLoadingPreview() { + val sampleSummary = DonationSummary( + totalAmount = 847, + monthlyAmount = 330, + categoryCnt = 3, + universityRank = 53 + ) + + DonationStatusContent( + donationSummary = sampleSummary, + donationStatus = null, + currentStatusType = DonationStatusType.ALL_UNIVERSITY, + onStatusTypeChanged = {}, + onDonateClick = {}, + isLoading = true + ) +} + +// 기부 기록이 없는 경우 +@Preview(showBackground = true, name = "기부 기록 없음") +@Composable +private fun DonationStatusEmptyPreview() { + val sampleSummary = DonationSummary( + totalAmount = 0, + monthlyAmount = 0, + categoryCnt = 0, + universityRank = 999 + ) + + val sampleStatus = DonationStatus( + planetAmount = 0, + peopleAmount = 0, + prosperityAmount = 0 + ) + + DonationStatusContent( + donationSummary = sampleSummary, + donationStatus = sampleStatus, + currentStatusType = DonationStatusType.MY_DONATION, + onStatusTypeChanged = {}, + onDonateClick = {}, + isLoading = false + ) +} + +// 한 카테고리만 기부한 경우 +@Preview(showBackground = true, name = "단일 카테고리 기부") +@Composable +private fun DonationStatusSingleCategoryPreview() { + val sampleSummary = DonationSummary( + totalAmount = 1500, + monthlyAmount = 500, + categoryCnt = 1, + universityRank = 25 + ) + + val sampleStatus = DonationStatus( + planetAmount = 1500, + peopleAmount = 0, + prosperityAmount = 0 + ) + + DonationStatusContent( + donationSummary = sampleSummary, + donationStatus = sampleStatus, + currentStatusType = DonationStatusType.MY_DONATION, + onStatusTypeChanged = {}, + onDonateClick = {}, + isLoading = false + ) +} + +// 큰 금액의 기부 현황 +@Preview(showBackground = true, name = "대규모 기부 현황") +@Composable +private fun DonationStatusLargeAmountPreview() { + val sampleSummary = DonationSummary( + totalAmount = 50000, + monthlyAmount = 15000, + categoryCnt = 3, + universityRank = 3 + ) + + val sampleStatus = DonationStatus( + planetAmount = 25000000, + peopleAmount = 15000000, + prosperityAmount = 35000000 + ) + + DonationStatusContent( + donationSummary = sampleSummary, + donationStatus = sampleStatus, + currentStatusType = DonationStatusType.ALL_UNIVERSITY, + onStatusTypeChanged = {}, + isLoading = false, + errorMessage = null, + onDonateClick = {} + ) +} + +// 오류 상태 +@Preview(showBackground = true, name = "오류 상태") +@Composable +private fun DonationStatusErrorPreview() { + DonationStatusContent( + donationSummary = null, + donationStatus = null, + currentStatusType = DonationStatusType.ALL_UNIVERSITY, + onStatusTypeChanged = {}, + onDonateClick = {}, + isLoading = false, + errorMessage = "기부 요약 정보를 불러오는 중 오류가 발생했습니다." + ) +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusUiState.kt new file mode 100644 index 0000000..c902710 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusUiState.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationStatus +import com.ssafy.tiggle.domain.entity.donation.DonationStatusType +import com.ssafy.tiggle.domain.entity.donation.DonationSummary + +data class DonationStatusUiState( + val isLoading: Boolean = false, + val donationSummary: DonationSummary? = null, + val donationStatus: DonationStatus? = null, + val currentStatusType: DonationStatusType = DonationStatusType.ALL_UNIVERSITY, + val errorMessage: String? = null +) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusViewModel.kt new file mode 100644 index 0000000..219c8c9 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationStatusViewModel.kt @@ -0,0 +1,87 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ssafy.tiggle.domain.entity.donation.DonationStatusType +import com.ssafy.tiggle.domain.usecase.donation.GetDonationStatusUseCase +import com.ssafy.tiggle.domain.usecase.donation.GetDonationSummaryUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DonationStatusViewModel @Inject constructor( + private val getDonationSummaryUseCase: GetDonationSummaryUseCase, + private val getDonationStatusUseCase: GetDonationStatusUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(DonationStatusUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + } + + private fun loadData() { + loadDonationSummary() + loadDonationStatus(_uiState.value.currentStatusType) + } + + fun loadDonationSummary() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + try { + // 기부 요약 정보 로드 + val summaryResult = getDonationSummaryUseCase() + if (summaryResult.isSuccess) { + _uiState.value = _uiState.value.copy(donationSummary = summaryResult.getOrNull()) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = "기부 요약 정보를 불러오는 중 오류가 발생했습니다." + ) + return@launch + } + + // 기부 현황 로드 (초기값: 전체 학교) + loadDonationStatus(_uiState.value.currentStatusType) + + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = "데이터를 불러오는 중 오류가 발생했습니다." + ) + } + } + } + + fun onStatusTypeChanged(type: DonationStatusType) { + if (_uiState.value.currentStatusType != type) { + _uiState.value = _uiState.value.copy(currentStatusType = type) + loadDonationStatus(type) + } + } + + fun loadDonationStatus(type: DonationStatusType) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val statusResult = getDonationStatusUseCase(type) + if (statusResult.isSuccess) { + _uiState.value = _uiState.value.copy( + isLoading = false, + donationStatus = statusResult.getOrNull() + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = "기부 현황을 불러오는 중 오류가 발생했습니다." + ) + } + } + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthScreen.kt new file mode 100644 index 0000000..80e89c8 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthScreen.kt @@ -0,0 +1,268 @@ +package com.ssafy.tiggle.presentation.ui.growth + +import androidx.compose.foundation.Image +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.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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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.presentation.ui.components.TiggleScreenLayout +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 com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText + +@Composable +fun GrowthScreen( + modifier: Modifier = Modifier, + onDonationHistoryClick: () -> Unit = {}, + onDonationStatusClick: () -> Unit = {}, + onDonationRankingClick: () -> Unit = {}, + viewModel: GrowthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + TiggleScreenLayout( + showBackButton = false, + showLogo = false + ) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 12.dp) + ) { + Spacer(Modifier.height(30.dp)) + + // 제목 + Text( + text = "나의 성장", + color = Color.Black, + fontSize = 22.sp, + style = AppTypography.headlineLarge + ) + Spacer(Modifier.height(6.dp)) + Text( + text = "작은 기부가 만든 변화를 확인해보세요", + color = TiggleGrayText, + fontSize = 13.sp, + style = AppTypography.bodySmall + ) + Spacer(Modifier.height(30.dp)) + + // 성장 카드 (아이콘들 포함) + GrowthCard( + totalAmount = uiState.totalDonationAmount, + nextGoalAmount = uiState.nextGoalAmount, + currentLevel = uiState.currentLevel, + onDonationHistoryClick = onDonationHistoryClick, + onDonationStatusClick = onDonationStatusClick, + onDonationRankingClick = onDonationRankingClick + ) + } + } +} + +@Composable +private fun GrowthIconRow( + onDonationHistoryClick: () -> Unit, + onDonationStatusClick: () -> Unit, + onDonationRankingClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + // 기부 기록 + Spacer(Modifier.width(6.dp)) + GrowthIconItem( + iconRes = R.drawable.donation_history_icon, + label = "기부 기록", + onClick = onDonationHistoryClick + ) + Spacer(Modifier.width(6.dp)) + // 현황 + GrowthIconItem( + iconRes = R.drawable.donation_status_icon, + label = "현황", + onClick = onDonationStatusClick + ) + Spacer(Modifier.width(6.dp)) + // 랭킹 + GrowthIconItem( + iconRes = R.drawable.donation_ranking, + label = "랭킹", + onClick = onDonationRankingClick + ) + } +} + +@Composable +private fun GrowthIconItem( + iconRes: Int, + label: String, + onClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable { onClick() } + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = label, + modifier = Modifier.size(50.dp) + ) + Spacer(Modifier.height(6.dp)) + Text( + text = label, + fontSize = 10.sp, + color = Color.Black, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun GrowthCard( + totalAmount: Int, + nextGoalAmount: Int, + currentLevel: String, + onDonationHistoryClick: () -> Unit, + onDonationStatusClick: () -> Unit, + onDonationRankingClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = TiggleGrayLight), + elevation = CardDefaults.cardElevation(0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 기부 관련 아이콘들 (기부 기록, 현황, 랭킹) + GrowthIconRow( + onDonationHistoryClick = onDonationHistoryClick, + onDonationStatusClick = onDonationStatusClick, + onDonationRankingClick = onDonationRankingClick + ) + + Spacer(Modifier.height(24.dp)) + + // 캐릭터 이미지 (현재는 비워둠) + Box( + modifier = Modifier + .size(200.dp) + .background( + Color.White.copy(alpha = 0.5f), + RoundedCornerShape(16.dp) + ), + contentAlignment = Alignment.Center + ) { + // TODO: 캐릭터 이미지 추가 + Text( + text = "캐릭터 영역", + color = TiggleGrayText, + fontSize = 14.sp + ) + } + + Spacer(Modifier.height(20.dp)) + + // 레벨 정보 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "레벨 7", + fontSize = 14.sp, + color = TiggleGrayText, + modifier = Modifier + .background( + Color.White.copy(alpha = 0.7f), + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) + + Spacer(Modifier.width(12.dp)) + + Text( + text = currentLevel, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = TiggleBlue + ) + } + + Spacer(Modifier.height(16.dp)) + + // 총 티끌 금액 + Text( + text = "총 티끌: ${Formatter.formatCurrency(totalAmount.toLong())}", + fontSize = 16.sp, + color = Color.Black, + fontWeight = FontWeight.Medium + ) + + Spacer(Modifier.height(12.dp)) + + // 진행 바 + LinearProgressIndicator( + progress = { 0.7f }, // TODO: 실제 진행률 계산 + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = TiggleBlue, + trackColor = Color.White.copy(alpha = 0.3f) + ) + + Spacer(Modifier.height(8.dp)) + + // 다음 레벨까지 필요한 금액 + Text( + text = "다음 레벨까지 ${Formatter.formatCurrency(nextGoalAmount.toLong())}", + fontSize = 12.sp, + color = TiggleGrayText + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun GrowthScreenPreview() { + GrowthScreen() +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthUiState.kt new file mode 100644 index 0000000..218fe62 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthUiState.kt @@ -0,0 +1,35 @@ +package com.ssafy.tiggle.presentation.ui.growth + +/** + * 성장 화면의 UI 상태 + */ +data class GrowthUiState( + val isLoading: Boolean = false, + val totalDonationAmount: Int = 17800, // 총 티끌 금액 + val nextGoalAmount: Int = 2500, // 다음 레벨까지 필요한 금액 + val currentLevel: String = "쓸", // 현재 레벨 + val characterStatus: String = "행복", // 캐릭터 상태 + val donationHistory: List = emptyList(), + val donationRanking: List = emptyList(), + val errorMessage: String? = null +) + +/** + * 기부 기록 아이템 + */ +data class DonationRecord( + val id: String, + val amount: Int, + val date: String, + val description: String +) + +/** + * 랭킹 아이템 + */ +data class RankingItem( + val rank: Int, + val name: String, + val amount: Int, + val isCurrentUser: Boolean = false +) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthViewModel.kt new file mode 100644 index 0000000..2081b7b --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/growth/GrowthViewModel.kt @@ -0,0 +1,43 @@ +package com.ssafy.tiggle.presentation.ui.growth + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class GrowthViewModel @Inject constructor() : ViewModel() { + + private val _uiState = MutableStateFlow(GrowthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // 초기 데이터 로드 (나중에 실제 API 연결) + loadGrowthData() + } + + private fun loadGrowthData() { + // TODO: 실제 API에서 데이터 로드 + // 현재는 더미 데이터 사용 + _uiState.value = _uiState.value.copy( + totalDonationAmount = 17800, + nextGoalAmount = 2500, + currentLevel = "쓸", + characterStatus = "행복" + ) + } + + fun onDonationHistoryClick() { + // TODO: 기부 기록 화면으로 이동 + } + + fun onDonationStatusClick() { + // TODO: 기부 현황 화면으로 이동 + } + + fun onDonationRankingClick() { + // TODO: 기부 랭킹 화면으로 이동 + } +} diff --git a/app/src/main/res/drawable/donation_history_icon.webp b/app/src/main/res/drawable/donation_history_icon.webp new file mode 100644 index 0000000..077ad39 Binary files /dev/null and b/app/src/main/res/drawable/donation_history_icon.webp differ diff --git a/app/src/main/res/drawable/donation_ranking.webp b/app/src/main/res/drawable/donation_ranking.webp new file mode 100644 index 0000000..d29aee6 Binary files /dev/null and b/app/src/main/res/drawable/donation_ranking.webp differ diff --git a/app/src/main/res/drawable/donation_status_icon.webp b/app/src/main/res/drawable/donation_status_icon.webp new file mode 100644 index 0000000..eaae206 Binary files /dev/null and b/app/src/main/res/drawable/donation_status_icon.webp differ diff --git a/app/src/main/res/drawable/people.webp b/app/src/main/res/drawable/people.webp new file mode 100644 index 0000000..e96278a Binary files /dev/null and b/app/src/main/res/drawable/people.webp differ diff --git a/app/src/main/res/drawable/planet.webp b/app/src/main/res/drawable/planet.webp new file mode 100644 index 0000000..fca6568 Binary files /dev/null and b/app/src/main/res/drawable/planet.webp differ diff --git a/app/src/main/res/drawable/prosperity.webp b/app/src/main/res/drawable/prosperity.webp new file mode 100644 index 0000000..8510688 Binary files /dev/null and b/app/src/main/res/drawable/prosperity.webp differ