diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 2a4e4db..5296f8a 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -13,6 +13,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + # google-services.json 파일 생성 + - name: Create google-services.json + run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > app/google-services.json + # 2. JDK 17 버전 설정 (핵심 수정 사항) - name: Set up JDK 17 uses: actions/setup-java@v4 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d81523..ea4049b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization) + id("com.google.gms.google-services") } android { @@ -83,6 +84,11 @@ dependencies { // Coroutines implementation(libs.kotlinx.coroutines.android) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3") + + //FCM + implementation(platform("com.google.firebase:firebase-bom:33.2.0")) + implementation("com.google.firebase:firebase-messaging-ktx") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b1eff71..482163d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,9 +1,9 @@ - + + + + + + + + + + android.util.Log.d("NotifPerm", "request result granted=$granted") + if (!granted) { + // 거부된 상태면 설정으로 유도(선택) + // open app notification settings + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, ctx.packageName) + } + ctx.startActivity(intent) + } + } + + LaunchedEffect(Unit) { + val granted = ContextCompat.checkSelfPermission( + ctx, Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + + Log.d("NotifPerm", "already granted=$granted") + + // 미허용이면 팝업 띄움 + if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } } \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/TiggleApplication.kt b/app/src/main/java/com/ssafy/tiggle/TiggleApplication.kt index c4e163a..aaaa927 100644 --- a/app/src/main/java/com/ssafy/tiggle/TiggleApplication.kt +++ b/app/src/main/java/com/ssafy/tiggle/TiggleApplication.kt @@ -1,6 +1,9 @@ package com.ssafy.tiggle import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build import dagger.hilt.android.HiltAndroidApp /** @@ -8,4 +11,28 @@ import dagger.hilt.android.HiltAndroidApp * Hilt를 사용하기 위한 Application 클래스 */ @HiltAndroidApp -class TiggleApplication : Application() +class TiggleApplication : Application() { + override fun onCreate() { + super.onCreate() + createNotificationChannel() // 🔔 앱 시작 시 채널 1회 생성 + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val id = getString(R.string.default_notification_channel_id) + val name = getString(R.string.notification_channel_name) + val desc = getString(R.string.notification_channel_desc) + + val channel = NotificationChannel( + id, + name, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = desc + enableVibration(true) + } + + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/core/fcm/FcmTokenUploader.kt b/app/src/main/java/com/ssafy/tiggle/core/fcm/FcmTokenUploader.kt new file mode 100644 index 0000000..7d52722 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/core/fcm/FcmTokenUploader.kt @@ -0,0 +1,53 @@ +package com.ssafy.tiggle.core.fcm + +import android.content.Context +import android.provider.Settings +import com.google.firebase.installations.FirebaseInstallations +import com.google.firebase.messaging.FirebaseMessaging +import com.ssafy.tiggle.domain.repository.FcmRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "FcmTokenUploader" + + +/** + * 앱 ↔ 서버 사이에서 FCM 토큰을 업로드하는 "작은 유틸 서비스" 클래스. + * + * 언제 쓰나? + * - 로그인 직후(서버가 유저 인증된 상태에서 토큰을 묶어 저장해야 하므로) + * - 혹은 onNewToken() 으로 토큰이 갱신되었을 때(서비스에서 직접 서버로 업로드) + */ +@Singleton +class FcmTokenUploader @Inject constructor( + private val repo: FcmRepository, + @ApplicationContext private val context: Context +) { + /** + * 단말 식별자(디바이스 ID)를 구함. + * - 1순위: Firebase Installation ID (FIID) : Firebase가 제공하는 안정적 설치 식별자 + * - 실패/예외 시: ANDROID_ID (OS가 제공하는 단말 고유 식별자) + */ + private suspend fun getDeviceId(): String { + return try { + FirebaseInstallations.getInstance().id.await() + } catch (_: Exception) { + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + ?: "unknown" + } + } + + /** + * 현재 단말의 FCM 토큰을 가져와 서버에 업로드. + * + * - suspend fun: 호출 측(viewModel 등)에서 코루틴으로 쉽게 호출 가능. + * - 실패 시 Result.failure 로 전달해서 UI에서 토스트/스낵바 처리 용이. + */ + suspend fun upload(): Result { + val token = FirebaseMessaging.getInstance().token.await() + val deviceId = getDeviceId() + return repo.registerToken(token) + } +} 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 new file mode 100644 index 0000000..c24dbd0 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/core/fcm/TiggleMessageService.kt @@ -0,0 +1,119 @@ +package com.ssafy.tiggle.core.fcm + +import android.Manifest +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.ssafy.tiggle.MainActivity +import com.ssafy.tiggle.R +import com.ssafy.tiggle.domain.repository.FcmRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "TiggleMessageService" + +/** + * FirebaseMessagingService 구현체. + * + * 역할 요약 + * 1) onNewToken(token): 단말의 FCM 토큰이 갱신될 때 호출 → 서버에 새 토큰 업로드 + * 2) onMessageReceived(msg): 포그라운드(또는 data-only) 수신 시 직접 알림 표시 + * + * 주의 + * - 앱이 "백그라운드"이고 메시지 payload에 "notification" 키가 있으면, + * 시스템이 알림을 자동으로 표시하고 onMessageReceived()는 호출되지 않는게 정상. + * - 앱이 포그라운드이거나 "data-only" payload면 onMessageReceived()가 호출됨. + */ + +@AndroidEntryPoint // Hilt로 Repository 등 주입 받을 수 있게 함 +class TiggleMessagingService : FirebaseMessagingService() { + + @Inject + lateinit var fcmRepository: FcmRepository + + /** + * 새로운 FCM 토큰이 발급/갱신되었을 때(앱 설치 직후, 데이터 초기화, 토큰 만료 등) + * -> 서버에 업로드해서 최신 토큰으로 푸시를 받을 수 있도록 함. + */ + override fun onNewToken(token: String) { + super.onNewToken(token) + CoroutineScope(Dispatchers.IO).launch { + try { + val deviceId = Settings.Secure.getString( + applicationContext.contentResolver, + Settings.Secure.ANDROID_ID + ) ?: "unknown" + + //서버로 토큰 전송 + fcmRepository.registerToken(token) + } catch (e: Exception) { + Log.e("TiggleFCM", "FCM 토큰 업로드 실패", e) + } + } + } + + /** + * 앱이 "포그라운드"일 때, 또는 "data-only" 메시지를 받았을 때 호출됨. + * + * ※ 백그라운드 + notification payload 조합이면 시스템이 자동으로 알림을 띄우며 + * 이 메서드는 호출되지 않는 게 정상 + * + * 권한 + * - Android 13+ 에서는 POST_NOTIFICATIONS 권한이 필요. + * 권한이 없으면 알림을 띄우지 않고 리턴. + */ + + override fun onMessageReceived(message: RemoteMessage) { + // notification payload + data payload 모두 수동 처리 + val title = message.data["title"] + ?: message.notification?.title + ?: "티끌" + val body = message.data["body"] + ?: message.notification?.body + ?: "새 알림이 도착했어요." + + Log.d(TAG, "onMessageReceived: data=${message.data}, notif=${message.notification}") + + // Android 13+ 권한 체크 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) return + + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pending = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // ✅ 항상 직접 알림 생성 + val channelId = getString(R.string.default_notification_channel_id) + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.logo) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pending) + .build() + + NotificationManagerCompat.from(this) + .notify((System.currentTimeMillis() % 100000).toInt(), notification) + } + +} diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/FcmApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/FcmApiService.kt new file mode 100644 index 0000000..8dab0df --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/FcmApiService.kt @@ -0,0 +1,14 @@ +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.fcm.FcmTokenRequestDto +import retrofit2.http.Body +import retrofit2.http.POST + +interface FcmApiService { + @POST("fcm/token") + suspend fun registerToken( + @Body body: FcmTokenRequestDto + ): BaseResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PiggyBankApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PiggyBankApiService.kt new file mode 100644 index 0000000..a307506 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/PiggyBankApiService.kt @@ -0,0 +1,37 @@ +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.piggybank.request.PrimaryAccountRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.VerificationCheckRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.VerificationRequestDto +import com.ssafy.tiggle.data.model.piggybank.response.AccountHolderResponseDto +import com.ssafy.tiggle.data.model.piggybank.response.VerificationCheckResponseDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface PiggyBankApiService { + @GET("accounts/holder") + suspend fun getAccountHolder( + @Query("accountNo") accountNo: String + ): Response> + + @POST("accounts/verification") + suspend fun requestOneWonVerification( + @Body body: VerificationRequestDto + ): BaseResponse + + @POST("accounts/verification/check") + suspend fun requestOneWonVerificationCheck( + @Body body: VerificationCheckRequestDto + ): BaseResponse + + @POST("accounts/primary") + suspend fun registerPrimaryAccount( + @Body body: PrimaryAccountRequestDto + ): BaseResponse +} +//0888315782686732 \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/EmptyResponse.kt b/app/src/main/java/com/ssafy/tiggle/data/model/EmptyResponse.kt new file mode 100644 index 0000000..7f1a437 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/EmptyResponse.kt @@ -0,0 +1,3 @@ +package com.ssafy.tiggle.data.model + +class EmptyResponse \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/fcm/FcmTokenRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/fcm/FcmTokenRequestDto.kt new file mode 100644 index 0000000..7395aa8 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/fcm/FcmTokenRequestDto.kt @@ -0,0 +1,5 @@ +package com.ssafy.tiggle.data.model.fcm + +data class FcmTokenRequestDto( + val fcmToken: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/PrimaryAccountRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/PrimaryAccountRequestDto.kt new file mode 100644 index 0000000..9311a8f --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/PrimaryAccountRequestDto.kt @@ -0,0 +1,6 @@ +package com.ssafy.tiggle.data.model.piggybank.request + +data class PrimaryAccountRequestDto( + val accountNo: String, + val verificationToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerificationCheckRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerificationCheckRequestDto.kt new file mode 100644 index 0000000..a562322 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerificationCheckRequestDto.kt @@ -0,0 +1,6 @@ +package com.ssafy.tiggle.data.model.piggybank.request + +data class VerificationCheckRequestDto( + val accountNo: String, + val authCode: String +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerificationRequestDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerificationRequestDto.kt new file mode 100644 index 0000000..4acab7a --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/request/VerificationRequestDto.kt @@ -0,0 +1,5 @@ +package com.ssafy.tiggle.data.model.piggybank.request + +data class VerificationRequestDto( + val accountNo: String +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/AccountHolderResponseDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/AccountHolderResponseDto.kt new file mode 100644 index 0000000..751fa63 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/AccountHolderResponseDto.kt @@ -0,0 +1,16 @@ +package com.ssafy.tiggle.data.model.piggybank.response + +import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder + +data class AccountHolderResponseDto( + val bankName: String, + val accountNo: String, + val userName: String +) + +fun AccountHolderResponseDto.toDomain(): AccountHolder = + AccountHolder( + bankName = bankName, + accountNo = accountNo, + userName = userName + ) diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/VerificationCheckResponseDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/VerificationCheckResponseDto.kt new file mode 100644 index 0000000..ef02419 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/piggybank/response/VerificationCheckResponseDto.kt @@ -0,0 +1,5 @@ +package com.ssafy.tiggle.data.model.piggybank.response + +data class VerificationCheckResponseDto( + val verificationToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/FcmRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/FcmRepositoryImpl.kt new file mode 100644 index 0000000..40e8fa0 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/FcmRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.ssafy.tiggle.data.repository + +import android.util.Log +import com.ssafy.tiggle.data.datasource.remote.FcmApiService +import com.ssafy.tiggle.data.model.fcm.FcmTokenRequestDto +import com.ssafy.tiggle.domain.repository.FcmRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FcmRepositoryImpl @Inject constructor( + private val fcmApiService: FcmApiService +) : FcmRepository { + override suspend fun registerToken( + token: String, + ): Result { + return try { + val res = fcmApiService.registerToken( + FcmTokenRequestDto( + fcmToken = token, + ) + ) + Log.d( + "FcmRepository", + "⬅️ /fcm/token result=${res.result}, message=${res.message}" + ) + if (res.result) Result.success(Unit) + else Result.failure(Exception(res.message)) + } catch (e: Exception) { + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/PiggyBankRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/PiggyBankRepositoryImpl.kt new file mode 100644 index 0000000..4006bc8 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/PiggyBankRepositoryImpl.kt @@ -0,0 +1,90 @@ +package com.ssafy.tiggle.data.repository + +import com.ssafy.tiggle.data.datasource.remote.PiggyBankApiService +import com.ssafy.tiggle.data.model.piggybank.request.PrimaryAccountRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.VerificationCheckRequestDto +import com.ssafy.tiggle.data.model.piggybank.request.VerificationRequestDto +import com.ssafy.tiggle.data.model.piggybank.response.toDomain +import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PiggyBankRepositoryImpl @Inject constructor( + private val piggyBankApiService: PiggyBankApiService +) : PiggyBankRepository { + override suspend fun getAccountHolder(accountNo: String): Result { + return try { + val response = piggyBankApiService.getAccountHolder(accountNo) + + if (response.isSuccessful) { + val body = response.body() + if (body?.result == true && body.data != null) { + Result.success(body.data.toDomain()) + } else { + Result.failure(Exception(body?.message ?: "예금주 조회에 실패했습니다.")) + } + } else { + val msg = when (response.code()) { + 400 -> "요청 형식이 올바르지 않습니다." + 404 -> "해당 계좌를 찾을 수 없습니다." + 409 -> "중복된 요청입니다." + 500 -> "서버 오류가 발생했습니다." + else -> "예금주 조회에 실패했습니다. (${response.code()})" + } + Result.failure(Exception(msg)) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun requestOneWonVerification(accountNo: String): Result { + return try { + val res = + piggyBankApiService.requestOneWonVerification(VerificationRequestDto(accountNo = accountNo)) + if (res.result) { + Result.success(Unit) + } else { + Result.failure(Exception(res.message ?: "1원 송금이 실패했습니다. ")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun requestOneWonCheckVerification( + accountNo: String, + authCode: String + ): Result { + return try { + val res = piggyBankApiService.requestOneWonVerificationCheck( + VerificationCheckRequestDto(accountNo = accountNo, authCode = authCode) + ) + if (res.result && res.data != null) { + Result.success(res.data.verificationToken) + } else { + Result.failure(Exception(res.message ?: "인증 코드 검증에 실패했습니다.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun registerPrimaryAccount( + accountNo: String, + verificationToken: String + ): Result { + return try { + val res = piggyBankApiService.registerPrimaryAccount( + PrimaryAccountRequestDto(accountNo, verificationToken) + ) + if (res.result) Result.success(Unit) + else Result.failure(Exception(res.message ?: "주 계좌 등록에 실패했습니다.")) + } catch (e: Exception) { + Result.failure(e) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt b/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt index 9d20884..151fbb1 100644 --- a/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt +++ b/app/src/main/java/com/ssafy/tiggle/di/NetworkModule.kt @@ -3,6 +3,8 @@ package com.ssafy.tiggle.di 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.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 dagger.Module @@ -30,7 +32,7 @@ object NetworkModule { fun providePrettyHttpLoggingInterceptor(): PrettyHttpLoggingInterceptor = PrettyHttpLoggingInterceptor() - /** ① 인증 없음: 로그인/재발급 등 */ + /** 인증 없음: 로그인/재발급 등 */ @Provides @Singleton @Named("noAuthClient") @@ -43,7 +45,7 @@ object NetworkModule { .writeTimeout(30, TimeUnit.SECONDS) .build() - /** ② no-auth Retrofit (auth 전용) */ + /** no-auth Retrofit (auth 전용) */ @Provides @Singleton @Named("noAuthRetrofit") @@ -55,14 +57,14 @@ object NetworkModule { .addConverterFactory(GsonConverterFactory.create()) .build() - /** ③ 기본 AuthApiService (UserRepositoryImpl에서 사용됨) */ + /** AuthApiService (UserRepositoryImpl에서 사용됨) */ @Provides @Singleton fun provideDefaultAuthApiService( @Named("noAuthRetrofit") retrofit: Retrofit ): AuthApiService = retrofit.create(AuthApiService::class.java) - /** ④ refresh 전용 AuthApiService (재발급에서만 사용) */ + /** refresh 전용 AuthApiService (재발급에서만 사용) */ @Provides @Singleton @Named("refresh") @@ -93,7 +95,7 @@ object NetworkModule { .writeTimeout(30, TimeUnit.SECONDS) .build() - /** ⑦ 일반 Retrofit (인증 필요한 API) */ + /** 일반 Retrofit (인증 필요한 API) */ @Provides @Singleton fun provideRetrofit( @@ -104,10 +106,19 @@ object NetworkModule { .addConverterFactory(GsonConverterFactory.create()) .build() - /** ⑧ 서비스들 */ + /** 서비스들 */ @Provides @Singleton fun provideUniversityApiService(retrofit: Retrofit): UniversityApiService = retrofit.create(UniversityApiService::class.java) + @Provides + @Singleton + fun providePiggyBankApiService(retrofit: Retrofit): PiggyBankApiService = + retrofit.create(PiggyBankApiService::class.java) + + @Provides + @Singleton + fun provideFcmApiService(retrofit: Retrofit): FcmApiService = + retrofit.create(FcmApiService::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 0e9c572..a644089 100644 --- a/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt +++ b/app/src/main/java/com/ssafy/tiggle/di/RepositoryModule.kt @@ -1,7 +1,11 @@ package com.ssafy.tiggle.di +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.FcmRepository +import com.ssafy.tiggle.domain.repository.PiggyBankRepository import com.ssafy.tiggle.domain.repository.UniversityRepository import com.ssafy.tiggle.domain.repository.UserRepository import dagger.Binds @@ -30,4 +34,15 @@ abstract class RepositoryModule { universityRepositoryImpl: UniversityRepositoryImpl ): UniversityRepository + @Binds + @Singleton + abstract fun bindPiggyBankRepository( + piggyBankRepositoryImpl: PiggyBankRepositoryImpl + ): PiggyBankRepository + + @Binds + @Singleton + abstract fun bindFcmRepository( + fcmRepositoryImpl: FcmRepositoryImpl + ): FcmRepository } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/AccountHolder.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/AccountHolder.kt new file mode 100644 index 0000000..89ec409 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/AccountHolder.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.domain.entity.piggybank + +data class AccountHolder( + val bankName: String="", + val accountNo: String="", + val userName: String="" +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/account/OpenAccount.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/OpenAccount.kt similarity index 98% rename from app/src/main/java/com/ssafy/tiggle/domain/entity/account/OpenAccount.kt rename to app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/OpenAccount.kt index 5dfac3c..f032f98 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/account/OpenAccount.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/OpenAccount.kt @@ -1,4 +1,4 @@ -package com.ssafy.tiggle.domain.entity.account +package com.ssafy.tiggle.domain.entity.piggybank data class OpenAccount( diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/account/RegisterAccount.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/RegisterAccount.kt similarity index 89% rename from app/src/main/java/com/ssafy/tiggle/domain/entity/account/RegisterAccount.kt rename to app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/RegisterAccount.kt index f09282a..ff658a8 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/entity/account/RegisterAccount.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/RegisterAccount.kt @@ -1,15 +1,13 @@ -package com.ssafy.tiggle.domain.entity.account +package com.ssafy.tiggle.domain.entity.piggybank data class RegisterAccount( val accountNum: String = "", - val owner:String="", - val code:Int=0, - val attemptsLeft: Int=3, - val bankName:String="", - val date:String="", - + val code: String = "", + val attemptsLeft: Int = 3, + val date: String = "", + val verificationToken: String = "", val accountNumError: String? = null, - val codeError:String?=null + val codeError: String? = null ) { /** 숫자 외 문자를 제거한 정규화(붙여넣기 대비) */ private fun sanitize(raw: String): String = raw.filter { it.isDigit() } @@ -40,10 +38,12 @@ data class RegisterAccount( accountNum ) ) + ValidationRegisterField.CODE -> copy(codeError = validateCode(code.toString())) } } + /** * 코드 유효성 검사 */ diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/VerificationCheckResult.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/VerificationCheckResult.kt new file mode 100644 index 0000000..41a8c37 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/piggybank/VerificationCheckResult.kt @@ -0,0 +1,5 @@ +package com.ssafy.tiggle.domain.entity.piggybank + +data class VerificationCheckResult( + val verificationToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/FcmRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/FcmRepository.kt new file mode 100644 index 0000000..5e38aba --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/FcmRepository.kt @@ -0,0 +1,5 @@ +package com.ssafy.tiggle.domain.repository + +interface FcmRepository { + suspend fun registerToken(token: String): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/PiggyBankRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/PiggyBankRepository.kt new file mode 100644 index 0000000..ae0f939 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/PiggyBankRepository.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.repository + +import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder + +interface PiggyBankRepository { + suspend fun getAccountHolder(accountNo: String): Result + suspend fun requestOneWonVerification(accountNo: String): Result + suspend fun requestOneWonCheckVerification(accountNo: String, authCode: String): Result + suspend fun registerPrimaryAccount( + accountNo: String, + verificationToken: String + ): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/GetAccountHolderUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/GetAccountHolderUseCase.kt new file mode 100644 index 0000000..acb7cb4 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/GetAccountHolderUseCase.kt @@ -0,0 +1,17 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject + +class GetAccountHolderUseCase @Inject constructor( + private val repository: PiggyBankRepository +) { + suspend operator fun invoke(accountNoRaw: String): Result { + val sanitized = accountNoRaw.filter { it.isDigit() } + if (sanitized.isBlank()) { + return Result.failure(IllegalArgumentException("계좌번호를 입력해주세요.")) + } + return repository.getAccountHolder(sanitized) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/PiggyBankUseCases.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/PiggyBankUseCases.kt new file mode 100644 index 0000000..0da10e8 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/PiggyBankUseCases.kt @@ -0,0 +1,10 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import javax.inject.Inject + +data class PiggyBankUseCases @Inject constructor( + val getAccountHolderUseCase: GetAccountHolderUseCase, + val requestOneWonVerificationUseCase: RequestOneWonVerificationUseCase, + val requestOneWonCheckVerificationUseCase: RequestOneWonCheckVerificationUseCase, + val registerPrimaryAccountUseCase: RegisterPrimaryAccountUseCase +) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RegisterPrimaryAccountUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RegisterPrimaryAccountUseCase.kt new file mode 100644 index 0000000..8ce3694 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RegisterPrimaryAccountUseCase.kt @@ -0,0 +1,11 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject + +class RegisterPrimaryAccountUseCase @Inject constructor( + private val repository: PiggyBankRepository +) { + suspend operator fun invoke(accountNo: String, token: String) = + repository.registerPrimaryAccount(accountNo, token) +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RequestOneWonCheckVerificationUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RequestOneWonCheckVerificationUseCase.kt new file mode 100644 index 0000000..af6ffa5 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RequestOneWonCheckVerificationUseCase.kt @@ -0,0 +1,11 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject + +class RequestOneWonCheckVerificationUseCase @Inject constructor( + private val repository: PiggyBankRepository +) { + suspend operator fun invoke(accountNo: String, authCode: String) = + repository.requestOneWonCheckVerification(accountNo, authCode) +} \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RequestOneWonVerificationUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RequestOneWonVerificationUseCase.kt new file mode 100644 index 0000000..50fda35 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/piggybank/RequestOneWonVerificationUseCase.kt @@ -0,0 +1,12 @@ +package com.ssafy.tiggle.domain.usecase.piggybank + +import com.ssafy.tiggle.domain.repository.PiggyBankRepository +import javax.inject.Inject + +class RequestOneWonVerificationUseCase @Inject constructor( + private val repository: PiggyBankRepository +) { + suspend operator fun invoke(accountNo: String): Result { + return repository.requestOneWonVerification(accountNo) + } +} \ 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 c80b24d..39708c6 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 @@ -93,7 +93,12 @@ fun NavigationGraph() { } is Screen.RegisterAccount -> NavEntry(key) { - RegisterAccountScreen() + RegisterAccountScreen( + onBackClick = { navBackStack.removeLastOrNull() }, + onFinish = { + navBackStack.removeLastOrNull() + } + ) } else -> throw IllegalArgumentException("Unknown route: $key") 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 36cc427..1fdee60 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 @@ -3,6 +3,7 @@ package com.ssafy.tiggle.presentation.ui.auth.login 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -16,7 +17,9 @@ import javax.inject.Inject */ @HiltViewModel class LoginViewModel @Inject constructor( - private val loginUserUseCase: LoginUserUseCase + private val loginUserUseCase: LoginUserUseCase, + private val fcmTokenUploader: FcmTokenUploader + ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) @@ -79,6 +82,7 @@ class LoginViewModel @Inject constructor( .onSuccess { // 로그인 성공 Log.d("LoginViewModel", "🎉 로그인 성공!") + fcmTokenUploader.upload() _uiState.value = _uiState.value.copy( isLoading = false, isLoginSuccess = true, 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 02c66ab..7cc93d5 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,7 +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.domain.entity.account.OpenAccount +import com.ssafy.tiggle.domain.entity.piggybank.OpenAccount import com.ssafy.tiggle.presentation.ui.components.TiggleAllAgreeCheckboxItem import com.ssafy.tiggle.presentation.ui.components.TiggleButton import com.ssafy.tiggle.presentation.ui.components.TiggleButtonVariant diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt index 6f561ae..ef92a3f 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountState.kt @@ -1,6 +1,6 @@ package com.ssafy.tiggle.presentation.ui.piggybank -import com.ssafy.tiggle.domain.entity.account.OpenAccount +import com.ssafy.tiggle.domain.entity.piggybank.OpenAccount data class OpenAccountState( val isLoading: Boolean = false, diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt index a8bf465..deb81c0 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/OpenAccountViewModel.kt @@ -1,7 +1,7 @@ package com.ssafy.tiggle.presentation.ui.piggybank import androidx.lifecycle.ViewModel -import com.ssafy.tiggle.domain.entity.account.ValidationField +import com.ssafy.tiggle.domain.entity.piggybank.ValidationField 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/piggybank/RegisterAccountScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountScreen.kt index fa5da76..0fa180c 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountScreen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonDefaults.outlinedButtonBorder import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -40,9 +40,10 @@ 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.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.ssafy.tiggle.R -import com.ssafy.tiggle.domain.entity.account.RegisterAccount +import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder +import com.ssafy.tiggle.domain.entity.piggybank.RegisterAccount import com.ssafy.tiggle.presentation.ui.components.TiggleButton import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout import com.ssafy.tiggle.presentation.ui.components.TiggleTextField @@ -55,26 +56,37 @@ import com.ssafy.tiggle.presentation.ui.theme.TiggleSkyBlue @Composable fun RegisterAccountScreen( modifier: Modifier = Modifier, - viewModel: RegisterAccountViewModel = viewModel(), - onBackClick: () -> Unit = {} + viewModel: RegisterAccountViewModel = hiltViewModel(), + onBackClick: () -> Unit = {}, + onFinish: () -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() + // 공통 Back 핸들러: 첫 단계면 pop, 아니면 단계-뒤로 + val handleTopBack: () -> Unit = { + if (uiState.registerAccountStep == RegisterAccountStep.ACCOUNT) { + onBackClick() // 스택에서 화면 제거 + } else { + viewModel.goToPreviousStep() + } + } + when (uiState.registerAccountStep) { RegisterAccountStep.ACCOUNT -> { AccountInputScreen( uiState = uiState, - onBackClick = onBackClick, + onBackClick = handleTopBack, onAccountChange = viewModel::updateAccountNum, - onNextClick = { viewModel.goToNextStep() } + onConfirmClick = { viewModel.fetchAccountHolder() }, + onDismissError = viewModel::clearError ) } RegisterAccountStep.ACCOUNTSUCCESS -> { AccountInputSuccessScreen( uiState = uiState, - onBackClick = onBackClick, - onNextClick = { viewModel.goToNextStep() } + onBackClick = handleTopBack, + onStartVerification = { viewModel.requestOneWon() } ) } @@ -82,7 +94,7 @@ fun RegisterAccountScreen( RegisterAccountStep.SENDCODE -> { SendCodeScreen( uiState = uiState, - onBackClick = onBackClick, + onBackClick = handleTopBack, onNextClick = { viewModel.goToNextStep() } ) } @@ -91,105 +103,117 @@ fun RegisterAccountScreen( CertificationScreen( uiState = uiState, onCodeChange = viewModel::updateCode, - onBackClick = { viewModel.goToPreviousStep() }, - onResendClick = { viewModel.goToPreviousStep() }, - onNextClick = { viewModel.goToNextStep() } + onBackClick = handleTopBack, + onResendClick = { viewModel.resendOneWon() }, + onNextClick = { viewModel.confirmCodeAndRegisterPrimary() } ) } RegisterAccountStep.SUCCESS -> { RegisterSuccessScreen( uiState = uiState, - onBackClick = onBackClick, - onNextClick = { viewModel.goToNextStep() } + onNextClick = onFinish ) } } } + @Composable fun AccountInputScreen( uiState: RegisterAccountState, onBackClick: () -> Unit, onAccountChange: (String) -> Unit, - onNextClick: () -> Unit, + onConfirmClick: () -> Unit, + onDismissError: () -> Unit ) { TiggleScreenLayout( showBackButton = true, + title = "계좌 등록", onBackClick = onBackClick, bottomButton = { - val nextEnabled = + val keyboard = LocalSoftwareKeyboardController.current + val buttonEnabled = uiState.registerAccount.accountNum.isNotBlank() && - uiState.registerAccount.accountNumError == null + uiState.registerAccount.accountNumError == null && + !uiState.isLoading + TiggleButton( - text = "확인", - onClick = onNextClick, - enabled = nextEnabled, + text = if (uiState.isLoading) "확인 중..." else "확인", + onClick = { + keyboard?.hide() + onConfirmClick() + }, + enabled = buttonEnabled, ) } - ) {} - - Column(Modifier.padding(16.dp)) { - // 상단 제목/뒤로 - Row( - Modifier - .fillMaxWidth() - .padding(60.dp, 15.dp), - horizontalArrangement = Arrangement.Start - ) { + ) { - Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) + Column(Modifier.padding(16.dp)) { - } + Spacer(Modifier.height(16.dp)) - Spacer(Modifier.height(16.dp)) + //상단 설명 + Image( + painter = painterResource(id = R.drawable.bank), contentDescription = "은행 아이콘", + Modifier + .size(110.dp) + .align(Alignment.CenterHorizontally) + ) - //상단 설명 - Image( - painter = painterResource(id = R.drawable.bank), contentDescription = "은행 아이콘", - Modifier - .size(110.dp) - .align(Alignment.CenterHorizontally) - ) + Spacer(Modifier.height(16.dp)) - Spacer(Modifier.height(16.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text( + text = "계좌 등록", + color = Color.Black, + fontSize = 22.sp, + style = AppTypography.headlineLarge, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = "잔돈 적립과 기부를 위해\n 내 계좌를 등록해주세요.", + color = TiggleGrayText, + fontSize = 13.sp, + style = AppTypography.bodySmall, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(100.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Text( - text = "계좌 등록", - color = Color.Black, - fontSize = 22.sp, - style = AppTypography.headlineLarge, - ) - Spacer(Modifier.height(6.dp)) - Text( - text = "잔돈 적립과 기부를 위해\n 내 계좌를 등록해주세요.", - color = TiggleGrayText, - fontSize = 13.sp, - style = AppTypography.bodySmall, - textAlign = TextAlign.Center - ) - } - Spacer(Modifier.height(100.dp)) - - Column(modifier = Modifier.padding(20.dp)) { - Text(text = "신한 은행 계좌번호", style = AppTypography.bodyLarge, fontSize = 15.sp) - Spacer(Modifier.height(3.dp)) - TiggleTextField( - uiState.registerAccount.accountNum, - onValueChange = onAccountChange, - label = "", - placeholder = "계좌번호를 입력해주세요.", - keyboardType = KeyboardType.Number, - isError = uiState.registerAccount.accountNumError != null, - errorMessage = uiState.registerAccount.accountNumError - ) + Column(modifier = Modifier.padding(20.dp)) { + Text(text = "신한 은행 계좌번호", style = AppTypography.bodyLarge, fontSize = 15.sp) + Spacer(Modifier.height(3.dp)) + TiggleTextField( + uiState.registerAccount.accountNum, + onValueChange = onAccountChange, + label = "", + placeholder = "계좌번호를 입력해주세요.", + keyboardType = KeyboardType.Number, + isError = uiState.registerAccount.accountNumError != null, + errorMessage = uiState.registerAccount.accountNumError + ) + } } + } + //실패 다이얼로그 + if (uiState.errorMessage != null) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismissError, + confirmButton = { + androidx.compose.material3.TextButton(onClick = onDismissError) { + Text("확인") + } + }, + title = { Text("확인 실패") }, + text = { Text(uiState.errorMessage!!) }, + shape = RoundedCornerShape(16.dp) + ) } } @@ -197,133 +221,122 @@ fun AccountInputScreen( fun AccountInputSuccessScreen( uiState: RegisterAccountState, onBackClick: () -> Unit, - onNextClick: () -> Unit, + onStartVerification: () -> Unit, ) { TiggleScreenLayout( showBackButton = true, + title = "계좌 등록", onBackClick = onBackClick, bottomButton = { - val nextEnabled = - uiState.registerAccount.accountNum.isNotBlank() && - uiState.registerAccount.accountNumError == null TiggleButton( - text = "1원 인증 시작", - onClick = onNextClick, - enabled = nextEnabled, + text = if (uiState.isLoading) "요청 중..." else "1원 인증 시작", + onClick = onStartVerification, + enabled = !uiState.isLoading ) } - ) {} - - Column(Modifier.padding(16.dp)) { - // 상단 제목/뒤로 - Row( - Modifier - .fillMaxWidth() - .padding(60.dp, 15.dp), - horizontalArrangement = Arrangement.Start - ) { - - Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) - - } - - Spacer(Modifier.height(16.dp)) + ) { - //상단 설명 - Image( - painter = painterResource(id = R.drawable.bank), contentDescription = "은행 아이콘", - Modifier - .size(110.dp) - .align(Alignment.CenterHorizontally) - ) + Column(Modifier.padding(20.dp)) { - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(10.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Text( - text = "계좌 등록", - color = Color.Black, - fontSize = 22.sp, - style = AppTypography.headlineLarge, - ) - Spacer(Modifier.height(6.dp)) - Text( - text = "잔돈 적립과 기부를 위해\n 내 계좌를 등록해주세요.", - color = TiggleGrayText, - fontSize = 13.sp, - style = AppTypography.bodySmall, - textAlign = TextAlign.Center + //상단 설명 + Image( + painter = painterResource(id = R.drawable.bank), contentDescription = "은행 아이콘", + Modifier + .size(110.dp) + .align(Alignment.CenterHorizontally) ) - } - Spacer(Modifier.height(100.dp)) - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.SpaceBetween) { - Text( - text = uiState.registerAccount.owner, - style = AppTypography.bodyLarge, - fontSize = 30.sp - ) - Spacer(Modifier.height(3.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .border(2.dp, TiggleGrayLight, RoundedCornerShape(16.dp)) - .padding(10.dp, 15.dp), - verticalAlignment = Alignment.CenterVertically + Spacer(Modifier.height(16.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(Alignment.CenterHorizontally) ) { - Image( - painter = painterResource(id = R.drawable.shinhan), - contentDescription = "신한 로고", - Modifier.size(50.dp) + Text( + text = "계좌 등록", + color = Color.Black, + fontSize = 22.sp, + style = AppTypography.headlineLarge, ) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(6.dp)) Text( - "신한은행 ${uiState.registerAccount.accountNum}", - style = AppTypography.bodyMedium, fontSize = 20.sp, textAlign = TextAlign.Center + text = "잔돈 적립과 기부를 위해\n 내 계좌를 등록해주세요.", + color = TiggleGrayText, + fontSize = 13.sp, + style = AppTypography.bodySmall, + textAlign = TextAlign.Center ) } - Spacer(Modifier.height(100.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) - .background(TiggleSkyBlue) // 연한 파란색 배경 - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // 아이콘 - Image( - painter = painterResource(id = R.drawable.lock), // 자물쇠 아이콘 리소스 - contentDescription = "보안 아이콘", - modifier = Modifier.size(24.dp) - ) - // 텍스트 - Column { + Column(verticalArrangement = Arrangement.SpaceBetween) { + Text( + text = uiState.accountHolder.userName, + style = AppTypography.bodyLarge, + fontSize = 30.sp + ) + Spacer(Modifier.height(3.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .border(2.dp, TiggleGrayLight, RoundedCornerShape(16.dp)) + .padding(10.dp, 15.dp), + verticalAlignment = Alignment.CenterVertically + + ) { + Image( + painter = painterResource(id = R.drawable.shinhan), + contentDescription = "신한 로고", + Modifier.size(50.dp) + ) + Spacer(Modifier.height(10.dp)) Text( - text = "안전한 계좌 등록", + "신한은행 ${uiState.accountHolder.accountNo}", style = AppTypography.bodyMedium, - color = Color(0xFF0077CC) // 강조 파란색 + fontSize = 15.sp, + textAlign = TextAlign.Center ) - Spacer(Modifier.height(4.dp)) - Text( - text = "계좌 정보는 암호화되어 안전하게 보관되며,\n" + - "1원 인증을 통해 계좌 소유주를 확인합니다.\n" + - "잔돈 적립과 더치페이 외에는 사용되지 않습니다.", - style = AppTypography.bodySmall, - color = TiggleGrayText + } + + Spacer(Modifier.height(30.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) + .background(TiggleSkyBlue) // 연한 파란색 배경 + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 아이콘 + Image( + painter = painterResource(id = R.drawable.lock), // 자물쇠 아이콘 리소스 + contentDescription = "보안 아이콘", + modifier = Modifier.size(24.dp) ) + + // 텍스트 + Column { + Text( + text = "안전한 계좌 등록", + style = AppTypography.bodyMedium, + color = Color(0xFF0077CC) // 강조 파란색 + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "계좌 정보는 암호화되어 안전하게 보관되며,\n" + + "1원 인증을 통해 계좌 소유주를 확인합니다.\n" + + "잔돈 적립과 더치페이 외에는 사용되지 않습니다.", + style = AppTypography.bodySmall, + color = TiggleGrayText + ) + } } } } - } } @@ -333,8 +346,12 @@ fun SendCodeScreen( onBackClick: () -> Unit, onNextClick: () -> Unit, ) { + // 하단 버튼 영역 만큼의 여유 (필요에 따라 조정: 80~96dp 권장) + val bottomBarPadding = 96.dp + TiggleScreenLayout( showBackButton = true, + title = "계좌 등록", onBackClick = onBackClick, bottomButton = { TiggleButton( @@ -343,48 +360,40 @@ fun SendCodeScreen( enabled = true ) } - ) {} - Column( - modifier = Modifier - .padding(20.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - Modifier + // ⬇️ 기존의 Column을 content 슬롯 안으로 이동 + Column( + modifier = Modifier .fillMaxWidth() - .padding(60.dp, 15.dp), - horizontalArrangement = Arrangement.Start + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + // 하단 고정 버튼과 겹치지 않도록 여유 공간 확보 + .padding(bottom = bottomBarPadding), + horizontalAlignment = Alignment.CenterHorizontally ) { - // 상단 타이틀 - Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) - } - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(10.dp)) - Image( - painter = painterResource(id = R.drawable.check), - contentDescription = "송금 완료 아이콘", - modifier = Modifier.size(170.dp) - ) + Image( + painter = painterResource(id = R.drawable.check), + contentDescription = "송금 완료 아이콘", + modifier = Modifier.size(170.dp) + ) - Spacer(Modifier.height(16.dp)) - - // 완료 안내 - Text("1원 송금 완료", style = AppTypography.headlineLarge, fontSize = 22.sp) - Spacer(Modifier.height(6.dp)) - Text( - "계좌로 1원이 입금되었습니다.\n입금자명을 확인해주세요.", - color = TiggleGrayText, - fontSize = 13.sp, - style = AppTypography.bodySmall, - textAlign = TextAlign.Center - ) + Spacer(Modifier.height(16.dp)) - Spacer(Modifier.height(40.dp)) + Text("1원 송금 완료", style = AppTypography.headlineLarge, fontSize = 22.sp) + Spacer(Modifier.height(6.dp)) + Text( + "계좌로 1원이 입금되었습니다.\n입금자명을 확인해주세요.", + color = TiggleGrayText, + fontSize = 13.sp, + style = AppTypography.bodySmall, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(40.dp)) - // 계좌 정보 박스 - Column(Modifier.padding(20.dp)) { Row( modifier = Modifier .fillMaxWidth() @@ -407,7 +416,6 @@ fun SendCodeScreen( Spacer(Modifier.height(20.dp)) - // 입금자명 확인 방법 Column( modifier = Modifier .fillMaxWidth() @@ -424,7 +432,6 @@ fun SendCodeScreen( Spacer(Modifier.height(10.dp)) - // 입금 확인 안내 박스 Row( modifier = Modifier .fillMaxWidth() @@ -436,7 +443,7 @@ fun SendCodeScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Image( - painter = painterResource(id = R.drawable.money), // 동전 or 금색 아이콘 + painter = painterResource(id = R.drawable.money), contentDescription = "입금 아이콘", modifier = Modifier.size(24.dp) ) @@ -457,10 +464,12 @@ fun SendCodeScreen( ) } } + } } } + @Composable fun CertificationScreen( uiState: RegisterAccountState, @@ -469,123 +478,122 @@ fun CertificationScreen( onResendClick: () -> Unit, onNextClick: () -> Unit, ) { - val code = uiState.registerAccount.code.toString() ?: "" + val code = uiState.registerAccount.code val error = uiState.registerAccount.codeError - val attemptsLeft = uiState.registerAccount.attemptsLeft ?: 3 - - val nextEnabled = code.length == 4 && error == null + val attemptsLeft = uiState.registerAccount.attemptsLeft TiggleScreenLayout( showBackButton = true, + title = "계좌 등록", onBackClick = onBackClick, bottomButton = { + val enabled = uiState.registerAccount.code.length == 4 && + uiState.registerAccount.codeError == null TiggleButton( - text = "인증 완료", + text = if (uiState.isLoading) "확인 중..." else "인증 완료", onClick = onNextClick, - enabled = nextEnabled + enabled = !uiState.isLoading && enabled ) } - ) {} - - Column( - modifier = Modifier - .padding(20.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally ) { - // 타이틀 - Row( - Modifier - .fillMaxWidth() - .padding(60.dp, 15.dp), - horizontalArrangement = Arrangement.Start - ) { - Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) - } - - Spacer(Modifier.height(30.dp)) Column( - Modifier.padding(20.dp), + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - Text("인증번호 입력", style = AppTypography.headlineLarge, fontSize = 22.sp) - Spacer(Modifier.height(6.dp)) - Text( - "계좌로 입금된 1원의 입금자명\n뒤 4자리를 입력하세요.", - style = AppTypography.bodySmall, - color = TiggleGrayText, - fontSize = 13.sp, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(50.dp)) + Spacer(Modifier.height(30.dp)) - // 입력 카드 Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .border(1.dp, TiggleGrayLight, RoundedCornerShape(16.dp)) - .padding(16.dp) + Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text("인증번호 (4자리)", style = AppTypography.bodyLarge, textAlign = TextAlign.Center) - Spacer(Modifier.height(12.dp)) - - OtpCodeBoxes( - value = code, - onValueChange = onCodeChange, - error = error, - boxCount = 4 + Text("인증번호 입력", style = AppTypography.headlineLarge, fontSize = 22.sp) + Spacer(Modifier.height(6.dp)) + Text( + "계좌로 입금된 1원의 입금자명\n뒤 4자리를 입력하세요.", + style = AppTypography.bodySmall, + color = TiggleGrayText, + fontSize = 13.sp, + textAlign = TextAlign.Center ) - Spacer(Modifier.height(8.dp)) - if (error != null) { - Text(error, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) - } else { + Spacer(Modifier.height(50.dp)) + + // 입력 카드 + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { Text( - "입금자명에서 뒤 4자리 숫자를 입력하세요.", - style = AppTypography.bodySmall, - color = TiggleGrayText, - fontSize = 12.sp + "인증번호 (4자리)", + style = AppTypography.bodyLarge, + textAlign = TextAlign.Center ) - } - } + Spacer(Modifier.height(12.dp)) - Spacer(Modifier.height(16.dp)) + OtpCodeBoxes( + value = code, + onValueChange = onCodeChange, + error = error, + boxCount = 4 + ) - // 남은 시도 횟수 - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(TiggleSkyBlue) - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center - ) { - Text("남은 인증 시도 횟수: ${attemptsLeft}회", style = AppTypography.bodySmall) - } + Spacer(Modifier.height(8.dp)) + if (error != null) { + Text(error, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) + } else { + Text( + "입금자명에서 뒤 4자리 숫자를 입력하세요.", + style = AppTypography.bodySmall, + color = TiggleGrayText, + fontSize = 12.sp + ) + } + } - Spacer(Modifier.height(28.dp)) + Spacer(Modifier.height(16.dp)) + + // 남은 시도 횟수 + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TiggleSkyBlue) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text("남은 인증 시도 횟수: ${attemptsLeft}회", style = AppTypography.bodySmall) + } - Text( - text = "인증번호를 받지 못하셨나요?", - style = AppTypography.bodySmall, - color = TiggleGrayText - ) - Spacer(Modifier.height(12.dp)) - OutlinedButton( - onClick = onResendClick, - shape = RoundedCornerShape(12.dp), - border = ButtonDefaults.outlinedButtonBorder, - contentPadding = PaddingValues(horizontal = 18.dp, vertical = 8.dp) - ) { - Text("1원 재송금", color = TiggleBlue) + Spacer(Modifier.height(28.dp)) + + Text( + text = "인증번호를 받지 못하셨나요?", + style = AppTypography.bodySmall, + color = TiggleGrayText + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onResendClick, + enabled = !uiState.isLoading, + shape = RoundedCornerShape(12.dp), + border = outlinedButtonBorder(enabled = !uiState.isLoading), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 8.dp) + ) { + + Text("1원 재송금", color = TiggleBlue) + } } - } - Spacer(Modifier.height(60.dp)) + Spacer(Modifier.height(60.dp)) + } } } @@ -658,12 +666,10 @@ private fun OtpCodeBoxes( @Composable fun RegisterSuccessScreen( uiState: RegisterAccountState, - onBackClick: () -> Unit, - onNextClick: () -> Unit, + onNextClick: () -> Unit ) { TiggleScreenLayout( - showBackButton = true, - onBackClick = onBackClick, + showBackButton = false, bottomButton = { TiggleButton( text = "확인", @@ -671,85 +677,82 @@ fun RegisterSuccessScreen( enabled = true ) } - ) {} - - Column( - modifier = Modifier - .padding(20.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally ) { - // 상단 타이틀 - Row( - Modifier - .fillMaxWidth() - .padding(60.dp, 15.dp), - horizontalArrangement = Arrangement.Start + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text("계좌 등록", style = AppTypography.headlineLarge, fontSize = 20.sp) - } - - Spacer(Modifier.height(40.dp)) - Image( - painter = painterResource(id = R.drawable.happy), - contentDescription = "계좌 등록 완료", - modifier = Modifier.size(150.dp) - ) + Spacer(Modifier.height(100.dp)) - Spacer(Modifier.height(24.dp)) - - // 안내 문구 - Text("계좌 등록 완료!", style = AppTypography.headlineLarge, fontSize = 22.sp) - Spacer(Modifier.height(6.dp)) - Text( - "1원 인증이 성공적으로 완료되어\n계좌가 등록되었습니다.", - style = AppTypography.bodySmall, - color = TiggleGrayText, - fontSize = 13.sp, - textAlign = TextAlign.Center - ) + Image( + painter = painterResource(id = R.drawable.happy), + contentDescription = "계좌 등록 완료", + modifier = Modifier.size(150.dp) + ) - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(24.dp)) - // 계좌 정보 박스 - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) - .padding(16.dp) - ) { - Text("등록된 계좌", style = AppTypography.bodyMedium, fontSize = 16.sp, color = Color.Black) + // 안내 문구 + Text("계좌 등록 완료!", style = AppTypography.headlineLarge, fontSize = 22.sp) + Spacer(Modifier.height(6.dp)) + Text( + "1원 인증이 성공적으로 완료되어\n계좌가 등록되었습니다.", + style = AppTypography.bodySmall, + color = TiggleGrayText, + fontSize = 13.sp, + textAlign = TextAlign.Center + ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(40.dp)) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + // 계좌 정보 박스 + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, TiggleGrayLight, RoundedCornerShape(12.dp)) + .padding(16.dp) ) { - Text("은행", style = AppTypography.bodySmall, color = TiggleGrayText) - Text(uiState.registerAccount.bankName, style = AppTypography.bodySmall, color = Color.Black) - } + Text( + "등록된 계좌", + style = AppTypography.bodyMedium, + fontSize = 16.sp, + color = Color.Black + ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text("계좌번호", style = AppTypography.bodySmall, color = TiggleGrayText) - Text(uiState.registerAccount.accountNum, style = AppTypography.bodySmall, color = Color.Black) - } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("은행", style = AppTypography.bodySmall, color = TiggleGrayText) + Text( + uiState.accountHolder.bankName, + style = AppTypography.bodySmall, + color = Color.Black + ) + } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("계좌번호", style = AppTypography.bodySmall, color = TiggleGrayText) + Text( + uiState.registerAccount.accountNum, + style = AppTypography.bodySmall, + color = Color.Black + ) + } + + Spacer(Modifier.height(8.dp)) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text("등록일시", style = AppTypography.bodySmall, color = TiggleGrayText) - Text(uiState.registerAccount.date, style = AppTypography.bodySmall, color = Color.Black) } } } @@ -769,7 +772,8 @@ fun AccountInputPreview() { ), onBackClick = {}, onAccountChange = {}, - onNextClick = {} + onConfirmClick = {}, + onDismissError = {} ) } @@ -779,14 +783,14 @@ fun AccountInputSuccessPreview() { AccountInputSuccessScreen( uiState = RegisterAccountState( registerAccountStep = RegisterAccountStep.ACCOUNTSUCCESS, - registerAccount = RegisterAccount( - accountNum = "110123456789", - owner = "최지원", - accountNumError = null + accountHolder = AccountHolder( + bankName = "신한은행", + accountNo = "123-456-78910", + userName = "최지원" ) ), onBackClick = {}, - onNextClick = {} + onStartVerification = {} ) } @@ -811,8 +815,7 @@ fun PreviewCertificationScreen_Success() { registerAccountStep = RegisterAccountStep.CERTIFICATION, registerAccount = RegisterAccount( accountNum = "110123456789", - owner = "최지원", - code = 1234, + code = "1234", codeError = null, attemptsLeft = 3 ) @@ -830,13 +833,16 @@ fun PreviewRegisterAccountSuccessScreen() { RegisterSuccessScreen( uiState = RegisterAccountState( registerAccountStep = RegisterAccountStep.SUCCESS, - registerAccount = RegisterAccount( + accountHolder = AccountHolder( bankName = "신한은행", + accountNo = "9393-9393-8394", + userName = "최지원" + ), + registerAccount = RegisterAccount( accountNum = "939393948394", date = "2020-10-20 39:39" ) ), - onBackClick = {}, onNextClick = {} ) } diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt index c4643d5..002e455 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountState.kt @@ -1,6 +1,7 @@ package com.ssafy.tiggle.presentation.ui.piggybank -import com.ssafy.tiggle.domain.entity.account.RegisterAccount +import com.ssafy.tiggle.domain.entity.piggybank.AccountHolder +import com.ssafy.tiggle.domain.entity.piggybank.RegisterAccount data class RegisterAccountState( val isLoading: Boolean = false, @@ -8,4 +9,7 @@ data class RegisterAccountState( val registerAccountStep: RegisterAccountStep = RegisterAccountStep.ACCOUNT, val registerAccount: RegisterAccount = RegisterAccount(), + + //예금주 정보 + val accountHolder: AccountHolder= AccountHolder() ) \ No newline at end of file diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt index 5272e71..78b8010 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/piggybank/RegisterAccountViewModel.kt @@ -1,15 +1,21 @@ package com.ssafy.tiggle.presentation.ui.piggybank import androidx.lifecycle.ViewModel -import com.ssafy.tiggle.domain.entity.account.ValidationRegisterField +import androidx.lifecycle.viewModelScope +import com.ssafy.tiggle.domain.entity.piggybank.RegisterAccount +import com.ssafy.tiggle.domain.entity.piggybank.ValidationRegisterField +import com.ssafy.tiggle.domain.usecase.piggybank.PiggyBankUseCases 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 RegisterAccountViewModel @Inject constructor( + val piggyBankUseCases: PiggyBankUseCases ) : ViewModel() { private val _uiState = MutableStateFlow(RegisterAccountState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -52,24 +58,11 @@ class RegisterAccountViewModel @Inject constructor( true } - RegisterAccountStep.ACCOUNTSUCCESS -> { - true - } - - RegisterAccountStep.SENDCODE -> { - true - } - - RegisterAccountStep.CERTIFICATION -> { - true - } - - RegisterAccountStep.SUCCESS -> { - true - } + else -> true } } + // 뒤로가기 버튼 클릭시 사용 fun goToPreviousStep() { val currentStep = _uiState.value.registerAccountStep val previousStep = when (currentStep) { @@ -83,6 +76,171 @@ class RegisterAccountViewModel @Inject constructor( _uiState.value = _uiState.value.copy(registerAccountStep = previousStep) } + //소유주 인증 실패시 오류 다이얼로그 닫기 + fun clearError() { + _uiState.value = _uiState.value.copy(errorMessage = null) + } + + fun fetchAccountHolder() { + val accNo = _uiState.value.registerAccount.accountNum + if (accNo.isBlank()) return + + // 로컬 유효성 먼저 + val current = _uiState.value.registerAccount + val err = current.validateAccountNum(current.accountNum) + if (err != null) { + _uiState.value = _uiState.value.copy(errorMessage = err) + return + } + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val result = piggyBankUseCases.getAccountHolderUseCase(accNo) + _uiState.value = result.fold( + onSuccess = { holder -> + _uiState.value.copy( + isLoading = false, + accountHolder = holder, + registerAccountStep = RegisterAccountStep.ACCOUNTSUCCESS + ) + }, + onFailure = { e -> + _uiState.value.copy( + isLoading = false, + errorMessage = e.message ?: "계좌 확인에 실패했어요. 잠시 후 다시 시도해주세요." + ) + } + ) + } + } + + //1원 송금 요청 + fun requestOneWon() { + val accountNo = _uiState.value.accountHolder.accountNo + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = piggyBankUseCases.requestOneWonVerificationUseCase(accountNo) + result.onSuccess { + _uiState.update { it.copy(isLoading = false) } + goToNextStep() + }.onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "1원 송금 요청에 실패했습니다." + ) + } + } + } + } + + /** 인증코드 확인 → verificationToken 저장 → 주계좌 등록 → 성공화면 */ + fun confirmCodeAndRegisterPrimary() { + val accountNo = _uiState.value.accountHolder.accountNo + val code = _uiState.value.registerAccount.code + + if (code.length != 4) { + _uiState.update { it.copy(errorMessage = "인증번호 4자리를 입력해주세요.") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + // 1) 인증 코드 검증 → 토큰 획득 + val check = piggyBankUseCases.requestOneWonCheckVerificationUseCase(accountNo, code) + check.onSuccess { token -> + // 상태에 저장 + _uiState.update { + it.copy(registerAccount = it.registerAccount.copy(verificationToken = token)) + } + + // 2) 주계좌 등록 + val reg = piggyBankUseCases.registerPrimaryAccountUseCase(accountNo, token) + reg.onSuccess { + _uiState.update { + it.copy( + isLoading = false, + registerAccountStep = RegisterAccountStep.SUCCESS + ) + } + }.onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "주계좌 등록에 실패했습니다." + ) + } + } + }.onFailure { e -> + // 인증 횟수 차감 처리 + val current = _uiState.value.registerAccount + val left = (current.attemptsLeft - 1).coerceAtLeast(0) + + if (left <= 0) { + // 0번 남으면 처음으로 리셋 + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "인증 실패 횟수를 초과했습니다. 처음부터 다시 시도해주세요.", + registerAccountStep = RegisterAccountStep.ACCOUNT, + registerAccount = RegisterAccount(), // 초기화 + accountHolder = com.ssafy.tiggle.domain.entity.piggybank.AccountHolder() + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "인증 코드 확인에 실패했습니다.", + registerAccount = current.copy( + attemptsLeft = left, + code = "", + codeError = null + ) + ) + } + } + } + } + } + + + fun resendOneWon() { + val accountNo = _uiState.value.accountHolder.accountNo + if (accountNo.isBlank()) { + _uiState.update { it.copy(errorMessage = "계좌정보가 없습니다. 처음부터 진행해주세요.") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val result = piggyBankUseCases.requestOneWonVerificationUseCase(accountNo) + result.onSuccess { + // 입력 중이던 코드 초기화 + SENDCODE 화면으로 되돌리기 + _uiState.update { + it.copy( + isLoading = false, + registerAccount = it.registerAccount.copy( + code = "", + codeError = null + ), + registerAccountStep = RegisterAccountStep.SENDCODE + ) + } + }.onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "1원 재송금에 실패했습니다." + ) + } + } + } + } + + // 사용자 데이터 업데이트 (도메인 엔티티의 유효성 검사 사용) fun updateAccountNum(accountNum: String) { val currentData = _uiState.value.registerAccount @@ -90,11 +248,12 @@ class RegisterAccountViewModel @Inject constructor( .validateField(ValidationRegisterField.ACCOUNT) _uiState.value = _uiState.value.copy(registerAccount = newData) } + fun updateCode(code: String) { val error = _uiState.value.registerAccount.validateCode(code) _uiState.value = _uiState.value.copy( registerAccount = _uiState.value.registerAccount.copy( - code = code.toIntOrNull() ?: 0, + code = code, codeError = error ) ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfeac41..ad2744a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ tiggle + 티끌 알림 + 티끌 알림을 받아요 + tiggle_alerts_high \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 5ba8ae0..8f9e8d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,6 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false + id("com.google.gms.google-services") version "4.4.2" apply false + } \ No newline at end of file