diff --git a/app/build.gradle b/app/build.gradle index 41d5050..0f28187 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-kapt' + id 'kotlin-parcelize' id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' id 'dagger.hilt.android.plugin' @@ -22,6 +23,7 @@ android { def localProperties = new Properties() localProperties.load(rootProject.file('./local.properties').newDataInputStream()) buildConfigField("String", "BASE_URL", localProperties['baseUrl']) + buildConfigField("String", "WEB_CLIENT_ID", localProperties["webClientId"]) } signingConfigs { @@ -65,6 +67,7 @@ android { kotlinOptions { jvmTarget = '1.8' } + buildFeatures { viewBinding true } @@ -78,17 +81,27 @@ dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'androidx.activity:activity-ktx:1.4.0' implementation 'com.google.android.material:material:1.5.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + // Lifecycle + def lifecycle_version = '2.4.1' + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + // Firebase implementation platform('com.google.firebase:firebase-bom:29.2.0') implementation 'com.google.firebase:firebase-analytics-ktx' implementation 'com.google.firebase:firebase-crashlytics-ktx' + // Google Login + implementation 'com.google.android.gms:play-services-auth:20.1.0' + // Hilt implementation "com.google.dagger:hilt-android:$hilt_version" kapt "com.google.dagger:hilt-android-compiler:$hilt_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 19ebc43..5a3f2e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ - + + android:theme="@style/Theme.MoyeoRunandroid" + android:usesCleartextTraffic="true"> + @@ -21,6 +25,9 @@ + \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/MainActivity.kt b/app/src/main/java/com/moyerun/moyeorun_android/MainActivity.kt index f04e0c1..dd97be1 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/MainActivity.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/MainActivity.kt @@ -1,7 +1,7 @@ package com.moyerun.moyeorun_android -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/EventLiveData.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/EventLiveData.kt new file mode 100644 index 0000000..ea6e1b5 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/EventLiveData.kt @@ -0,0 +1,65 @@ +package com.moyerun.moyeorun_android.common + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer + +typealias EventLiveData = LiveData> + +class MutableEventLiveData : MutableLiveData>() { + + var event: T? + @Deprecated("getter is NOT supported!", level = DeprecationLevel.ERROR) + get() = throw UnsupportedOperationException() + set(value) { + if (value != null) { + setValue(Event(value)) + } + } + + fun postEvent(value: T?) { + if (value != null) { + postValue(Event(value)) + } + } +} + +/** + * Used as a wrapper for data that is exposed via a LiveData that represents an event. + */ +open class Event(private val content: T) { + + var hasBeenHandled = false + private set // Allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + /** + * Returns the content, even if it's already been handled. + */ + fun peekContent(): T = content +} + +/** + * An [Observer] for [Event]s, simplifying the pattern of checking if the [Event]'s content has + * already been handled. + * + * [onEventUnhandledContent] is *only* called if the [Event]'s contents has not been handled. + */ +class EventObserver(private val onEventUnhandledContent: (T) -> Unit) : Observer> { + override fun onChanged(event: Event?) { + event?.getContentIfNotHandled()?.let { value -> + onEventUnhandledContent(value) + } + } +} diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/di/CoroutineDispatcherModule.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/di/CoroutineDispatcherModule.kt new file mode 100644 index 0000000..e1a0d39 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/di/CoroutineDispatcherModule.kt @@ -0,0 +1,28 @@ +package com.moyerun.moyeorun_android.common.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Qualifier +annotation class IODispatcher + +@Qualifier +annotation class DefaultDispatcher + +@InstallIn(SingletonComponent::class) +@Module +object CoroutineDispatcherModule { + + @IODispatcher + @Provides + fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/dialog/RoundDialogFragment.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/dialog/RoundDialogFragment.kt index 24c4ab4..b0d7d9d 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/common/dialog/RoundDialogFragment.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/dialog/RoundDialogFragment.kt @@ -9,7 +9,7 @@ import android.view.Window import androidx.fragment.app.DialogFragment import com.moyerun.moyeorun_android.common.extension.isActivityDestroyed -open class RoundDialogFragment : DialogFragment() { +abstract class RoundDialogFragment : DialogFragment() { private var dismissOnPause = false diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/exceptions/ApiException.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/exceptions/ApiException.kt index 75c2ab2..990fd23 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/common/exceptions/ApiException.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/exceptions/ApiException.kt @@ -2,10 +2,14 @@ package com.moyerun.moyeorun_android.common.exceptions import com.moyerun.moyeorun_android.network.api.Error -class ApiException(val url: String, val error: Error) : RuntimeException() { +open class ApiException(val url: String, val error: Error) : RuntimeException() { val case: String get() = error.case override val message: String? get() = error.message + + override fun toString(): String { + return "${super.toString()} url: $url, error: $error" + } } \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ActivityExtension.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ActivityExtension.kt index af4d5e4..afa7533 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ActivityExtension.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ActivityExtension.kt @@ -4,6 +4,15 @@ import android.app.Activity import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentActivity +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.moyerun.moyeorun_android.R +import com.moyerun.moyeorun_android.common.EventLiveData +import com.moyerun.moyeorun_android.common.EventObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch fun Activity.toast(msg: String, isShort: Boolean = true) { Toast.makeText(this, msg, if (isShort) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show() @@ -14,4 +23,19 @@ inline fun FragmentActivity.showAllowingStateLoss( dialogFragmentFactory: () -> DialogFragment ) { supportFragmentManager.showAllowingStateLoss(tag, dialogFragmentFactory) +} + +fun Activity.showNetworkErrorToast() { + toast(getString(R.string.toast_network_error)) +} + +fun ComponentActivity.repeatOnStart(block: suspend CoroutineScope.() -> Unit) { + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED, block) } +} + +fun ComponentActivity.observeEvent( + event: EventLiveData, + onEventUnhandledContent: (T) -> Unit +) { + event.observe(this, EventObserver(onEventUnhandledContent)) } \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FirebaseExctension.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FirebaseExctension.kt index 16e6fc4..fae4d4b 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FirebaseExctension.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FirebaseExctension.kt @@ -21,7 +21,7 @@ fun FirebaseCrashlytics.recordException(logLevel: LogLevel, message: String, thr class FirebaseLogException : Exception { constructor(logLevel: LogLevel, message: String) : super("$logLevel : $message") - constructor(logLevel: LogLevel, cause: Throwable) : super(logLevel.toString(), cause) + constructor(logLevel: LogLevel, cause: Throwable) : super("$logLevel : $cause", cause) constructor( logLevel: LogLevel, diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FragmentExtension.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FragmentExtension.kt index e36ee18..46be75b 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FragmentExtension.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/FragmentExtension.kt @@ -4,6 +4,14 @@ import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.moyerun.moyeorun_android.R +import com.moyerun.moyeorun_android.common.EventLiveData +import com.moyerun.moyeorun_android.common.EventObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch fun Fragment.toast(msg: String, isShort: Boolean = false) { Toast.makeText(context, msg, if (isShort) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show() @@ -19,4 +27,18 @@ inline fun FragmentManager?.showAllowingStateLoss( val transaction = beginTransaction() transaction.add(dialogFragmentFactory(), tag) transaction.commitAllowingStateLoss() +} + +fun Fragment.showNetworkErrorToast() { + toast(getString(R.string.toast_network_error)) +} + +fun Fragment.repeatOnStart(block: suspend CoroutineScope.() -> Unit) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block) + } +} + +fun Fragment.observeEvent(event: EventLiveData, onEventUnhandledContent: (T) -> Unit) { + event.observe(viewLifecycleOwner, EventObserver(onEventUnhandledContent)) } \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ViewExtension.kt b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ViewExtension.kt index bb0e66e..909c80b 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ViewExtension.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/common/extension/ViewExtension.kt @@ -1,6 +1,8 @@ package com.moyerun.moyeorun_android.common.extension import android.view.View +import android.widget.RadioButton +import android.widget.TextView fun View.setOnDebounceClickListener(interval: Long = 1000L, action: (View?) -> Unit) { val debounceClickListener = object : View.OnClickListener { @@ -16,4 +18,17 @@ fun View.setOnDebounceClickListener(interval: Long = 1000L, action: (View?) -> U } } setOnClickListener(debounceClickListener) +} + +fun TextView.setTextIfNew(text: CharSequence?) { + if (this.text.contentEquals(text).not()) { + setText(text) + } +} + +fun RadioButton.setCheckIfNew(check: Boolean) { + val oldValue = isChecked + if (oldValue != check) { + isChecked = check + } } \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/AuthException.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/AuthException.kt new file mode 100644 index 0000000..5b27baf --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/AuthException.kt @@ -0,0 +1,6 @@ +package com.moyerun.moyeorun_android.login + +import com.moyerun.moyeorun_android.common.exceptions.ApiException +import com.moyerun.moyeorun_android.network.api.Error + +class AuthException(url: String, error: Error): ApiException(url, error) \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/ProviderType.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/ProviderType.kt new file mode 100644 index 0000000..524ee3b --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/ProviderType.kt @@ -0,0 +1,9 @@ +package com.moyerun.moyeorun_android.login + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class ProviderType : Parcelable { + GOOGLE +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/SignUpMetaData.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/SignUpMetaData.kt new file mode 100644 index 0000000..62d3796 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/SignUpMetaData.kt @@ -0,0 +1,16 @@ +package com.moyerun.moyeorun_android.login + +import android.os.Parcelable +import com.moyerun.moyeorun_android.login.ProviderType +import kotlinx.parcelize.Parcelize + +/** + * 회원가입을 위해 프로필 설정 화면으로 진입 시 + * 회원가입 API 호출에 필요한 메타 데이터를 전달하기 위한 + * DataHolder + */ +@Parcelize +data class SignUpMetaData( + val idToken: String, + val providerType: ProviderType +) : Parcelable diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/data/AuthModule.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/data/AuthModule.kt new file mode 100644 index 0000000..f32dcae --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/data/AuthModule.kt @@ -0,0 +1,15 @@ +package com.moyerun.moyeorun_android.login.data + +import com.moyerun.moyeorun_android.login.data.impl.AuthRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +abstract class AuthModule { + + @Binds + abstract fun bindsAuthRepository(repository: AuthRepositoryImpl): AuthRepository +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/data/AuthRepository.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/data/AuthRepository.kt new file mode 100644 index 0000000..d39db81 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/data/AuthRepository.kt @@ -0,0 +1,16 @@ +package com.moyerun.moyeorun_android.login.data + +import com.moyerun.moyeorun_android.login.ProviderType +import com.moyerun.moyeorun_android.login.data.model.SignInResponse +import com.moyerun.moyeorun_android.login.data.model.SignUpRequest +import com.moyerun.moyeorun_android.network.api.Success +import com.moyerun.moyeorun_android.network.calladapter.ApiResult + +interface AuthRepository { + suspend fun signIn( + idToken: String, + providerType: ProviderType + ): ApiResult + + suspend fun signUp(signUpRequest: SignUpRequest): ApiResult +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/data/impl/AuthRepositoryImpl.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/data/impl/AuthRepositoryImpl.kt new file mode 100644 index 0000000..7742e9f --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/data/impl/AuthRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.moyerun.moyeorun_android.login.data.impl + +import com.moyerun.moyeorun_android.common.di.IODispatcher +import com.moyerun.moyeorun_android.login.ProviderType +import com.moyerun.moyeorun_android.login.data.AuthRepository +import com.moyerun.moyeorun_android.login.data.model.SignInResponse +import com.moyerun.moyeorun_android.login.data.model.SignUpRequest +import com.moyerun.moyeorun_android.network.MoyeorunNetworkDataSource +import com.moyerun.moyeorun_android.network.calladapter.ApiResult +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val network: MoyeorunNetworkDataSource, + @IODispatcher private val coroutineDispatcher: CoroutineDispatcher +) : AuthRepository { + + override suspend fun signIn( + idToken: String, + providerType: ProviderType + ): ApiResult { + return withContext(coroutineDispatcher) { + network.signIn(idToken = idToken, providerType = providerType) + } + } + + override suspend fun signUp(signUpRequest: SignUpRequest): ApiResult { + return withContext(coroutineDispatcher) { + network.signUp(signUpRequest) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignInRequest.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignInRequest.kt new file mode 100644 index 0000000..d7a8ff3 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignInRequest.kt @@ -0,0 +1,6 @@ +package com.moyerun.moyeorun_android.login.data.model + +data class SignInRequest( + val idToken: String, + val providerType: String +) diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignInResponse.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignInResponse.kt new file mode 100644 index 0000000..35c9936 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignInResponse.kt @@ -0,0 +1,12 @@ +package com.moyerun.moyeorun_android.login.data.model + +data class SignInResponse( + val token: TokenPair, + val userId: String, + val isNewUser: Boolean, +) + +data class TokenPair( + val accessToken: String, + val refreshToken: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignUpRequest.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignUpRequest.kt new file mode 100644 index 0000000..0790c40 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/data/model/SignUpRequest.kt @@ -0,0 +1,9 @@ +package com.moyerun.moyeorun_android.login.data.model + +data class SignUpRequest( + val idToken: String, + val providerType: String, + val nickName: String, + val gender: String, + val image: String +) \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginActivity.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginActivity.kt new file mode 100644 index 0000000..c418ff2 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginActivity.kt @@ -0,0 +1,132 @@ +package com.moyerun.moyeorun_android.login.ui + +import android.content.Intent +import android.content.IntentSender +import android.os.Bundle +import android.provider.Settings +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.auth.api.identity.BeginSignInRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.auth.api.identity.SignInClient +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.CommonStatusCodes +import com.moyerun.moyeorun_android.BuildConfig +import com.moyerun.moyeorun_android.MainActivity +import com.moyerun.moyeorun_android.R +import com.moyerun.moyeorun_android.common.Lg +import com.moyerun.moyeorun_android.common.extension.observeEvent +import com.moyerun.moyeorun_android.common.extension.showNetworkErrorToast +import com.moyerun.moyeorun_android.common.extension.toast +import com.moyerun.moyeorun_android.databinding.ActivityLoginBinding +import com.moyerun.moyeorun_android.profile.ui.ProfileEditActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class LoginActivity : AppCompatActivity() { + + private val viewModel: LoginViewModel by viewModels() + + private val oneTapClient: SignInClient by lazy { Identity.getSignInClient(this) } + private val signInRequest: BeginSignInRequest by lazy { getBeginSignInRequest() } + + private val beginSignInResultLauncher = + registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + if (result != null) { + try { + val credential = oneTapClient.getSignInCredentialFromIntent(result.data) + val idToken = credential.googleIdToken + if (idToken != null) { + Lg.i("Success. token : $idToken") + viewModel.googleSignIn(idToken) + } else { + showUnknownErrorToast() + Lg.fe("No ID token") + } + } catch (e: ApiException) { + when (e.statusCode) { + CommonStatusCodes.CANCELED -> { /*Doing nothing*/ } + CommonStatusCodes.NETWORK_ERROR -> { + showNetworkErrorToast() + Lg.e("One-tap encountered a network error. $e") + } + else -> { + showUnknownErrorToast() + Lg.fe("Couldn't get credential from result.", e) + } + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding.root) + + observeEvent(viewModel.loginEvent) { event -> + when (event) { + is LoginEvent.RegisteredUser -> { + startActivity(Intent(this, MainActivity::class.java)) + finish() + } + is LoginEvent.NewUser -> { + ProfileEditActivity.startActivity(this, event.signUpMetaData) + } + is LoginEvent.Error -> { + showUnknownErrorToast() + } + } + } + + binding.buttonLoginGoogle.setOnClickListener { + oneTapClient.beginSignIn(signInRequest) + .addOnSuccessListener(this) { result -> + try { + val intentSenderRequest = + IntentSenderRequest.Builder(result.pendingIntent.intentSender).build() + beginSignInResultLauncher.launch(intentSenderRequest) + } catch (e: IntentSender.SendIntentException) { + showUnknownErrorToast() + Lg.fe("Couldn't start One Tab UI", e) + } + } + .addOnFailureListener(this) { + // 기기에 등록된 계정이 없는 경우 호출 + startDeviceGoogleSignInActivity() + // 간혹 등록된 계정이 있는데도 해당 콜백을 타는 경우가 있어서 로깅 + Lg.fe("No Google Accounts found", it) + } + } + } + + private fun startDeviceGoogleSignInActivity() { + startActivity(Intent(Settings.ACTION_ADD_ACCOUNT).apply { + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) + }) + } + + private fun getBeginSignInRequest(): BeginSignInRequest { + return BeginSignInRequest.builder() + .setGoogleIdTokenRequestOptions( + BeginSignInRequest.GoogleIdTokenRequestOptions.builder() + .setSupported(true) + .setServerClientId(BuildConfig.WEB_CLIENT_ID) + // false 로 설정해서 앱에 로그인한 적이 있는 계정뿐만 아니라 + // 기기에 등록된 구글 계정을 모두 보여준다 + .setFilterByAuthorizedAccounts(false) + .build() + ) + // 하나의 계정만 있다면 자동으로 선택 + .setAutoSelectEnabled(true) + .build() + } + + private fun showUnknownErrorToast() { + toast(getString(R.string.login_toast_unknown_error)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginEvent.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginEvent.kt new file mode 100644 index 0000000..a5e634b --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginEvent.kt @@ -0,0 +1,9 @@ +package com.moyerun.moyeorun_android.login.ui + +import com.moyerun.moyeorun_android.login.SignUpMetaData + +sealed class LoginEvent { + object RegisteredUser : LoginEvent() + data class NewUser(val signUpMetaData: SignUpMetaData) : LoginEvent() + object Error : LoginEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginViewModel.kt b/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginViewModel.kt new file mode 100644 index 0000000..29132fc --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/login/ui/LoginViewModel.kt @@ -0,0 +1,53 @@ +package com.moyerun.moyeorun_android.login.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.moyerun.moyeorun_android.common.EventLiveData +import com.moyerun.moyeorun_android.common.Lg +import com.moyerun.moyeorun_android.common.MutableEventLiveData +import com.moyerun.moyeorun_android.login.ProviderType +import com.moyerun.moyeorun_android.login.SignUpMetaData +import com.moyerun.moyeorun_android.login.data.AuthRepository +import com.moyerun.moyeorun_android.network.calladapter.onFailure +import com.moyerun.moyeorun_android.network.calladapter.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow + get() = _isLoading + + private val _loginEvent = MutableEventLiveData() + val loginEvent: EventLiveData + get() = _loginEvent + + fun googleSignIn(idToken: String) { + signInInternal(idToken, ProviderType.GOOGLE) + } + + private fun signInInternal(idToken: String, providerType: ProviderType) { + viewModelScope.launch { + _isLoading.value = true + authRepository.signIn(idToken, providerType) + .onSuccess { + _loginEvent.event = if (it.isNewUser) { + LoginEvent.NewUser(SignUpMetaData(idToken, providerType)) + } else { + LoginEvent.RegisteredUser + } + }.onFailure { + Lg.fe(it) + _loginEvent.event = LoginEvent.Error + } + _isLoading.value = false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunNetworkDataSource.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunNetworkDataSource.kt new file mode 100644 index 0000000..4a0d909 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunNetworkDataSource.kt @@ -0,0 +1,17 @@ +package com.moyerun.moyeorun_android.network + +import com.moyerun.moyeorun_android.login.ProviderType +import com.moyerun.moyeorun_android.login.data.model.SignInResponse +import com.moyerun.moyeorun_android.login.data.model.SignUpRequest +import com.moyerun.moyeorun_android.network.api.Success +import com.moyerun.moyeorun_android.network.calladapter.ApiResult + +interface MoyeorunNetworkDataSource { + + suspend fun signIn( + idToken: String, + providerType: ProviderType + ): ApiResult + + suspend fun signUp(signUpRequest: SignUpRequest): ApiResult +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunNetworkDataSourceImpl.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunNetworkDataSourceImpl.kt new file mode 100644 index 0000000..a16e6fa --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunNetworkDataSourceImpl.kt @@ -0,0 +1,29 @@ +package com.moyerun.moyeorun_android.network + +import com.moyerun.moyeorun_android.login.ProviderType +import com.moyerun.moyeorun_android.login.data.model.SignInRequest +import com.moyerun.moyeorun_android.login.data.model.SignInResponse +import com.moyerun.moyeorun_android.login.data.model.SignUpRequest +import com.moyerun.moyeorun_android.network.api.Success +import com.moyerun.moyeorun_android.network.calladapter.ApiResult + +class MoyeorunNetworkDataSourceImpl( + private val moyeorunService: MoyeorunService +) : MoyeorunNetworkDataSource { + + override suspend fun signIn( + idToken: String, + providerType: ProviderType + ): ApiResult { + return moyeorunService.signIn( + SignInRequest( + idToken = idToken, + providerType = providerType.name.uppercase() + ) + ) + } + + override suspend fun signUp(signUpRequest: SignUpRequest): ApiResult { + return moyeorunService.signUp(signUpRequest) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunService.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunService.kt new file mode 100644 index 0000000..7e00e6c --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/network/MoyeorunService.kt @@ -0,0 +1,18 @@ +package com.moyerun.moyeorun_android.network + +import com.moyerun.moyeorun_android.login.data.model.SignInRequest +import com.moyerun.moyeorun_android.login.data.model.SignInResponse +import com.moyerun.moyeorun_android.login.data.model.SignUpRequest +import com.moyerun.moyeorun_android.network.api.Success +import com.moyerun.moyeorun_android.network.calladapter.ApiResult +import retrofit2.http.Body +import retrofit2.http.POST + +interface MoyeorunService { + + @POST("/api/auth/sign-in") + suspend fun signIn(@Body signInRequest: SignInRequest): ApiResult + + @POST("/api/auth/sign-up") + suspend fun signUp(@Body signUpRequest: SignUpRequest): ApiResult +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/api/ApiErrorCase.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/api/ApiErrorCase.kt index abba91d..53d3934 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/network/api/ApiErrorCase.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/network/api/ApiErrorCase.kt @@ -1,20 +1,29 @@ package com.moyerun.moyeorun_android.network.api import com.moyerun.moyeorun_android.common.exceptions.ApiException +import com.moyerun.moyeorun_android.login.AuthException enum class ApiErrorCase(val case: String) { // 서버와 합의된 에러 케이스들을 정의합니다. - NOT_LOGIN("100"), // TODO : 예시입니다. 실제 로그인 작업 시 수정해주세요. + AUTHORIZATION_FAIL("101"), + EXPIRE_JWT("104"), + NOT_LOGIN("105"), + DUPLICATE_NICKNAME("110"), + DUPLICATE_USER("111"), UNKNOWN("999"); companion object { - private fun findError(case: String): ApiErrorCase { + fun findError(case: String): ApiErrorCase { return values().find { it.case == case } ?: UNKNOWN } fun getException(url: String, error: Error, cause: Throwable? = null): Throwable { - return when(findError(error.case)) { - NOT_LOGIN -> { TODO() } + return when (findError(error.case)) { + AUTHORIZATION_FAIL, + EXPIRE_JWT, + NOT_LOGIN, + DUPLICATE_NICKNAME, + DUPLICATE_USER -> AuthException(url, error) else -> ApiException(url, error) } } diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/api/ApiService.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/api/NetworkResponse.kt similarity index 50% rename from app/src/main/java/com/moyerun/moyeorun_android/network/api/ApiService.kt rename to app/src/main/java/com/moyerun/moyeorun_android/network/api/NetworkResponse.kt index 9b3d4dc..1d31e05 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/network/api/ApiService.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/network/api/NetworkResponse.kt @@ -1,5 +1,5 @@ package com.moyerun.moyeorun_android.network.api -interface ApiService { - -} \ No newline at end of file +data class NetworkResponse( + val data: T +) diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/calladapter/ApiResult.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/calladapter/ApiResult.kt index e21e8cf..a887750 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/network/calladapter/ApiResult.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/network/calladapter/ApiResult.kt @@ -1,5 +1,8 @@ package com.moyerun.moyeorun_android.network.calladapter +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + /** * Success : API 호출 성공 시, body를 Wrapping 합니다. * Failure : API 호출 실패 시, throwable을 Wrpping 합니다. @@ -7,4 +10,25 @@ package com.moyerun.moyeorun_android.network.calladapter sealed class ApiResult { data class Success(val body: T) : ApiResult() data class Failure(val throwable: Throwable) : ApiResult() +} + +inline fun ApiResult.onSuccess(action: (data: T) -> Unit): ApiResult { + if (this is ApiResult.Success) { + action.invoke(body) + } + return this +} + +inline fun ApiResult.onFailure(action: (throwable: Throwable) -> Unit): ApiResult { + if (this is ApiResult.Failure) { + action.invoke(throwable) + } + return this +} + +inline fun ApiResult.map(transform: (value: T) -> R): ApiResult { + return when (this) { + is ApiResult.Success -> ApiResult.Success(transform(body)) + is ApiResult.Failure -> ApiResult.Failure(throwable) + } } \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/client/Retrofit.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/client/Retrofit.kt deleted file mode 100644 index f0146ef..0000000 --- a/app/src/main/java/com/moyerun/moyeorun_android/network/client/Retrofit.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.moyerun.moyeorun_android.network.client - -import com.moyerun.moyeorun_android.BuildConfig -import com.moyerun.moyeorun_android.network.MoyeorunJsonConverterFactory -import com.moyerun.moyeorun_android.network.api.ApiService -import com.moyerun.moyeorun_android.network.calladapter.ApiResultCallAdapterFactory -import retrofit2.Retrofit - -private const val BASE_URL = BuildConfig.BASE_URL - -val retrofit: Retrofit = Retrofit.Builder() - .baseUrl(BASE_URL) - .addCallAdapterFactory(ApiResultCallAdapterFactory()) - .addConverterFactory(MoyeorunJsonConverterFactory.create()) - .build() - -val apiService: ApiService = retrofit.create(ApiService::class.java) \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/network/di/NetworkModule.kt b/app/src/main/java/com/moyerun/moyeorun_android/network/di/NetworkModule.kt new file mode 100644 index 0000000..e862ca2 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/network/di/NetworkModule.kt @@ -0,0 +1,34 @@ +package com.moyerun.moyeorun_android.network.di + +import com.moyerun.moyeorun_android.BuildConfig +import com.moyerun.moyeorun_android.network.MoyeorunJsonConverterFactory +import com.moyerun.moyeorun_android.network.MoyeorunNetworkDataSource +import com.moyerun.moyeorun_android.network.MoyeorunNetworkDataSourceImpl +import com.moyerun.moyeorun_android.network.MoyeorunService +import com.moyerun.moyeorun_android.network.calladapter.ApiResultCallAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit + +private const val BASE_URL = BuildConfig.BASE_URL + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + fun providesRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .addCallAdapterFactory(ApiResultCallAdapterFactory()) + .addConverterFactory(MoyeorunJsonConverterFactory.create()) + .build() + } + + @Provides + fun providesMoyeorunNetworkDataSource(retrofit: Retrofit): MoyeorunNetworkDataSource { + return MoyeorunNetworkDataSourceImpl(retrofit.create(MoyeorunService::class.java)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileEditActivity.kt b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileEditActivity.kt new file mode 100644 index 0000000..027170d --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileEditActivity.kt @@ -0,0 +1,172 @@ +package com.moyerun.moyeorun_android.profile.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.doAfterTextChanged +import com.moyerun.moyeorun_android.MainActivity +import com.moyerun.moyeorun_android.R +import com.moyerun.moyeorun_android.common.Lg +import com.moyerun.moyeorun_android.common.extension.* +import com.moyerun.moyeorun_android.databinding.ActivityProfileBinding +import com.moyerun.moyeorun_android.login.SignUpMetaData +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class ProfileEditActivity : AppCompatActivity() { + + private val viewModel: ProfileEditViewModel by viewModels() + + private val galleryLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> + if (imageUri != null) { + viewModel.onImageUrlChanged(imageUri) + } else { + Lg.fw("Cannot get Image Uri from gallery") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityProfileBinding.inflate(layoutInflater) + setContentView(binding.root) + + val signUpMetaData: SignUpMetaData? = intent.getParcelableExtra(EXTRA_SIGN_UP_META_DATA) + val originalProfile: ProfileUiModel? = intent.getParcelableExtra(EXTRA_PROFILE_UI_MODEL) + val isNewProfile = (originalProfile == null && signUpMetaData != null) + + if (isNewProfile) { + binding.textviewProfileTitle.text = "기본 정보" + binding.buttonProfileConfirm.text = "다음" + } else { + binding.textviewProfileTitle.text = "프로필" + binding.buttonProfileConfirm.text = "완료" + } + + viewModel.updateData(signUpMetaData, originalProfile) + + binding.edittextProfileNickname.doAfterTextChanged { + viewModel.onNicknameChanged(it?.toString().orEmpty()) + } + + binding.badgeimageviewProfileImage.setOnDebounceClickListener { + showAllowingStateLoss("selectImage") { + ProfileImageSelectDialogFragment.getInstance( + onGalleryClick = { + galleryLauncher.launch("image/*") + }, + onDefaultImagesClick = { + // Todo : 기본 이미지 선택 화면 + } + ) + } + } + + binding.radiogroupProfileGender.setOnCheckedChangeListener { _, checkedId -> + val gender = when (checkedId) { + R.id.radiobutton_profile_man -> Gender.MALE + R.id.radiobutton_profile_woman -> Gender.FEMALE + else -> Gender.NONE + } + viewModel.onGenderChanged(gender) + } + + binding.buttonProfileConfirm.setOnDebounceClickListener { + if (isNewProfile) { + viewModel.signUp() + } + } + + repeatOnStart { + launch { + viewModel.profileUiModel + .map { it.nickname } + .distinctUntilChanged() + .collect { + binding.edittextProfileNickname.setTextIfNew(it) + } + } + launch { + viewModel.profileUiModel + .map { it.imageUri } + .distinctUntilChanged() + .collect { + binding.badgeimageviewProfileImage.setBigCircleImgSrc(it) + } + } + launch { + viewModel.profileUiModel + .map { it.gender } + .distinctUntilChanged() + .collect { + when (it) { + Gender.MALE -> binding.radiobuttonProfileMan.setCheckIfNew(true) + Gender.FEMALE -> binding.radiobuttonProfileWoman.setCheckIfNew(true) + Gender.NONE -> binding.radiogroupProfileGender.clearCheck() + } + } + } + launch { + viewModel.isButtonEnabled + .collect { buttonEnabled -> + binding.buttonProfileConfirm.isEnabled = buttonEnabled + } + } + } + + observeEvent(viewModel.profileEvent) { + when (it) { + ProfileEvent.SUCCESS_SIGN_UP -> { + startActivity(Intent(this, MainActivity::class.java)) + } + } + } + + observeEvent(viewModel.profileErrorEvent) { + when (it) { + ProfileError.DUPLICATE_NICKNAME -> { + binding.alertDuplicate() + } + ProfileError.WRONG_ACCESS -> { + toast(getString(R.string.profile_toast_wrong_access)) + finish() + } + ProfileError.UNKNOWN, ProfileError.UNKNOWN_AUTH -> { + toast(getString(R.string.profile_toast_unknown_error)) + } + ProfileError.NETWORK -> { + showNetworkErrorToast() + } + } + } + } + + private fun ActivityProfileBinding.alertDuplicate() { + edittextProfileNickname.setTextAppearance(R.style.Profile_Input_Wrong) + } + + companion object { + private const val EXTRA_PROFILE_UI_MODEL = "profileUiModel" + private const val EXTRA_SIGN_UP_META_DATA = "signUpMetaData" + + // 프로필 수정 시 사용 + fun startActivity(context: Context, profileUiModel: ProfileUiModel) { + context.startActivity(Intent(context, ProfileEditActivity::class.java).apply { + putExtra(EXTRA_PROFILE_UI_MODEL, profileUiModel) + }) + } + + // 회원가입 시 사용 + fun startActivity(context: Context, signUpMetaData: SignUpMetaData) { + context.startActivity(Intent(context, ProfileEditActivity::class.java).apply { + putExtra(EXTRA_SIGN_UP_META_DATA, signUpMetaData) + }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileEditViewModel.kt b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileEditViewModel.kt new file mode 100644 index 0000000..7c386f2 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileEditViewModel.kt @@ -0,0 +1,148 @@ +package com.moyerun.moyeorun_android.profile.ui + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.moyerun.moyeorun_android.common.EventLiveData +import com.moyerun.moyeorun_android.common.Lg +import com.moyerun.moyeorun_android.common.MutableEventLiveData +import com.moyerun.moyeorun_android.common.exceptions.NetworkException +import com.moyerun.moyeorun_android.login.AuthException +import com.moyerun.moyeorun_android.login.data.AuthRepository +import com.moyerun.moyeorun_android.login.SignUpMetaData +import com.moyerun.moyeorun_android.network.api.ApiErrorCase +import com.moyerun.moyeorun_android.network.calladapter.onFailure +import com.moyerun.moyeorun_android.network.calladapter.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProfileEditViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _profileUiModel = MutableStateFlow(ProfileUiModel()) + val profileUiModel: StateFlow + get() = _profileUiModel + + private val _profileEvent = MutableEventLiveData() + val profileEvent: EventLiveData + get() = _profileEvent + + private val _profileErrorEvent = MutableEventLiveData() + val profileErrorEvent: EventLiveData + get() = _profileErrorEvent + + private val _isButtonEnabled = MutableStateFlow(false) + val isButtonEnabled: StateFlow + get() = _isButtonEnabled + + private var signUpMetaData: SignUpMetaData? = null + private var oldProfileUiModel: ProfileUiModel? = null + + private var isNewProfile = true + + fun updateData(signUpMetaData: SignUpMetaData?, profileUiModel: ProfileUiModel?) { + when { + signUpMetaData == null && profileUiModel != null -> { + _profileUiModel.update { profileUiModel } + oldProfileUiModel = profileUiModel + isNewProfile = false + } + signUpMetaData != null && profileUiModel == null -> { + this.signUpMetaData = signUpMetaData + isNewProfile = true + } + else -> { + Lg.fw("Wrong access. signUpMetadata: $signUpMetaData, originProfile: $profileUiModel") + _profileErrorEvent.event = ProfileError.WRONG_ACCESS + } + } + } + + fun onNicknameChanged(nickname: String) = updateWithValidate { + _profileUiModel.update { + it.copy(nickname = nickname) + } + _profileUiModel.value + } + + fun onImageUrlChanged(imageUri: Uri) { + _profileUiModel.update { + it.copy(imageUri = imageUri) + } + } + + fun onGenderChanged(gender: Gender) = updateWithValidate { + _profileUiModel.update { + it.copy(gender = gender) + } + _profileUiModel.value + } + + fun signUp() { + val metaData = signUpMetaData + if (metaData == null) { + Lg.fw("SignUp Failure. meta data is null") + _profileErrorEvent.event = ProfileError.UNKNOWN + return + } + viewModelScope.launch { + authRepository.signUp(profileUiModel.value.toSignUpRequest(metaData)) + .onSuccess { + _profileEvent.event = ProfileEvent.SUCCESS_SIGN_UP + Lg.d(it.toString()) + } + .onFailure { throwable -> + when (throwable) { + is AuthException -> throwable.onAuthFailure() + is NetworkException -> throwable.onNetworkFailure() + else -> throwable.onUnknownFailure() + } + } + } + } + + private fun updateWithValidate(updateAction: () -> ProfileUiModel) { + val updatedProfileUiModel = updateAction.invoke() + _isButtonEnabled.update { + updatedProfileUiModel.validate() + } + } + + private fun ProfileUiModel.validate(): Boolean { + return nickname.validateNickname() + && gender != Gender.NONE + } + + private fun String.validateNickname(): Boolean { + return isNotEmpty() + } + + private fun AuthException.onAuthFailure() { + when (ApiErrorCase.findError(case)) { + ApiErrorCase.DUPLICATE_NICKNAME -> { + _profileErrorEvent.event = ProfileError.DUPLICATE_NICKNAME + Lg.e(this) + } + else -> { + _profileErrorEvent.event = ProfileError.UNKNOWN_AUTH + Lg.fe(this) + } + } + } + + private fun NetworkException.onNetworkFailure() { + _profileErrorEvent.event = ProfileError.NETWORK + Lg.e(this) + } + + private fun Throwable.onUnknownFailure() { + _profileErrorEvent.event = ProfileError.UNKNOWN + Lg.fe(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileImageSelectDialogFragment.kt b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileImageSelectDialogFragment.kt new file mode 100644 index 0000000..89f7f14 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileImageSelectDialogFragment.kt @@ -0,0 +1,50 @@ +package com.moyerun.moyeorun_android.profile.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.moyerun.moyeorun_android.common.dialog.RoundDialogFragment +import com.moyerun.moyeorun_android.common.extension.setOnDebounceClickListener +import com.moyerun.moyeorun_android.databinding.DialogProfileImageSelectBinding + +class ProfileImageSelectDialogFragment : RoundDialogFragment() { + + private var onGalleryClick: () -> Unit = {} + private var onDefaultImagesClick: () -> Unit = {} + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return DialogProfileImageSelectBinding.inflate(inflater, container, false).root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val binding = DialogProfileImageSelectBinding.bind(view) + + binding.buttonImageSelectGallery.setOnDebounceClickListener { + onGalleryClick.invoke() + dismissAllowingStateLoss() + } + + binding.buttonImageSelectDefault.setOnDebounceClickListener { + onDefaultImagesClick.invoke() + dismissAllowingStateLoss() + } + } + + companion object { + fun getInstance( + onGalleryClick: () -> Unit, + onDefaultImagesClick: () -> Unit + ): ProfileImageSelectDialogFragment { + return ProfileImageSelectDialogFragment().apply { + this.onGalleryClick = onGalleryClick + this.onDefaultImagesClick = onDefaultImagesClick + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileUiModel.kt b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileUiModel.kt new file mode 100644 index 0000000..1d10c14 --- /dev/null +++ b/app/src/main/java/com/moyerun/moyeorun_android/profile/ui/ProfileUiModel.kt @@ -0,0 +1,40 @@ +package com.moyerun.moyeorun_android.profile.ui + +import android.net.Uri +import android.os.Parcelable +import com.moyerun.moyeorun_android.login.data.model.SignUpRequest +import com.moyerun.moyeorun_android.login.SignUpMetaData +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ProfileUiModel( + val imageUri: Uri = Uri.EMPTY, + val nickname: String = "", + val gender: Gender = Gender.NONE +) : Parcelable + +fun ProfileUiModel.toSignUpRequest(signUpMetaData: SignUpMetaData): SignUpRequest { + return SignUpRequest( + idToken = signUpMetaData.idToken, + providerType = signUpMetaData.providerType.name, + nickName = nickname, + gender = gender.name, + image = "TEST" //TODO: 이미지 Uri 넣기 + ) +} + +enum class ProfileEvent { + SUCCESS_SIGN_UP +} + +enum class ProfileError { + WRONG_ACCESS, + DUPLICATE_NICKNAME, + UNKNOWN_AUTH, + NETWORK, + UNKNOWN +} + +enum class Gender { + MALE, FEMALE, NONE +} \ No newline at end of file diff --git a/app/src/main/java/com/moyerun/moyeorun_android/views/BadgeRoundImageView.kt b/app/src/main/java/com/moyerun/moyeorun_android/views/BadgeRoundImageView.kt index 9108791..59b41e3 100644 --- a/app/src/main/java/com/moyerun/moyeorun_android/views/BadgeRoundImageView.kt +++ b/app/src/main/java/com/moyerun/moyeorun_android/views/BadgeRoundImageView.kt @@ -2,6 +2,7 @@ package com.moyerun.moyeorun_android.views import android.Manifest import android.content.Context +import android.net.Uri import android.util.AttributeSet import android.util.Patterns import android.view.LayoutInflater @@ -88,9 +89,17 @@ class BadgeRoundImageView @JvmOverloads constructor( @RequiresPermission(Manifest.permission.INTERNET) fun setBigCircleImgSrc(imgUrl: String, isImageCropped: Boolean = false) { if (isImageCropped) { - Glide.with(context).load(imgUrl).centerCrop().into(binding.imgBigCircle) + Glide.with(context).load(imgUrl).centerCrop() + .placeholder(R.drawable.user_profile_image_default_112dp).into(binding.imgBigCircle) } else - Glide.with(context).load(imgUrl).into(binding.imgBigCircle) + Glide.with(context).load(imgUrl) + .placeholder(R.drawable.user_profile_image_default_112dp).into(binding.imgBigCircle) + } + + fun setBigCircleImgSrc(imageUri: Uri) { + Glide.with(context).load(imageUri).centerCrop() + .placeholder(R.drawable.user_profile_image_default_112dp) + .into(binding.imgBigCircle) } fun setBigCircleImageBg(@ColorRes bgResId: Int) { diff --git a/app/src/main/res/drawable/gender_background.xml b/app/src/main/res/drawable/gender_background.xml new file mode 100644 index 0000000..9a187a5 --- /dev/null +++ b/app/src/main/res/drawable/gender_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gender_background_checked.xml b/app/src/main/res/drawable/gender_background_checked.xml new file mode 100644 index 0000000..9503dee --- /dev/null +++ b/app/src/main/res/drawable/gender_background_checked.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gender_button_man.xml b/app/src/main/res/drawable/gender_button_man.xml new file mode 100644 index 0000000..7aaffa5 --- /dev/null +++ b/app/src/main/res/drawable/gender_button_man.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gender_button_man_checked.xml b/app/src/main/res/drawable/gender_button_man_checked.xml new file mode 100644 index 0000000..1238092 --- /dev/null +++ b/app/src/main/res/drawable/gender_button_man_checked.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gender_button_woman.xml b/app/src/main/res/drawable/gender_button_woman.xml new file mode 100644 index 0000000..1b3c591 --- /dev/null +++ b/app/src/main/res/drawable/gender_button_woman.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gender_button_woman_checked.xml b/app/src/main/res/drawable/gender_button_woman_checked.xml new file mode 100644 index 0000000..1c8be3a --- /dev/null +++ b/app/src/main/res/drawable/gender_button_woman_checked.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_gender_man.xml b/app/src/main/res/drawable/ic_gender_man.xml new file mode 100644 index 0000000..95fc05c --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_man.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gender_man_white.xml b/app/src/main/res/drawable/ic_gender_man_white.xml new file mode 100644 index 0000000..71745b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_man_white.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gender_woman.xml b/app/src/main/res/drawable/ic_gender_woman.xml new file mode 100644 index 0000000..1c22bb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_woman.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gender_woman_white.xml b/app/src/main/res/drawable/ic_gender_woman_white.xml new file mode 100644 index 0000000..87715ff --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_woman_white.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_toolbar_back.xml b/app/src/main/res/drawable/ic_toolbar_back.xml new file mode 100644 index 0000000..128ab24 --- /dev/null +++ b/app/src/main/res/drawable/ic_toolbar_back.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/login_google_logo.xml b/app/src/main/res/drawable/login_google_logo.xml new file mode 100644 index 0000000..5051ec3 --- /dev/null +++ b/app/src/main/res/drawable/login_google_logo.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/login_logo.xml b/app/src/main/res/drawable/login_logo.xml new file mode 100644 index 0000000..f901f5d --- /dev/null +++ b/app/src/main/res/drawable/login_logo.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/round_background_12.xml b/app/src/main/res/drawable/round_background_12.xml index cfb6cbf..06b257f 100644 --- a/app/src/main/res/drawable/round_background_12.xml +++ b/app/src/main/res/drawable/round_background_12.xml @@ -1,9 +1,6 @@ - - - - - - - - \ No newline at end of file + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_gender_man.xml b/app/src/main/res/drawable/selector_gender_man.xml new file mode 100644 index 0000000..3dd0ff1 --- /dev/null +++ b/app/src/main/res/drawable/selector_gender_man.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_gender_woman.xml b/app/src/main/res/drawable/selector_gender_woman.xml new file mode 100644 index 0000000..136e3c9 --- /dev/null +++ b/app/src/main/res/drawable/selector_gender_woman.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_round_button_text.xml b/app/src/main/res/drawable/selector_round_button_text.xml deleted file mode 100644 index 5ef8ee7..0000000 --- a/app/src/main/res/drawable/selector_round_button_text.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_border_warning_edittext.xml b/app/src/main/res/drawable/shape_border_warning_edittext.xml new file mode 100644 index 0000000..750652b --- /dev/null +++ b/app/src/main/res/drawable/shape_border_warning_edittext.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toolbar_divider_background.xml b/app/src/main/res/drawable/toolbar_divider_background.xml new file mode 100644 index 0000000..3a8ba01 --- /dev/null +++ b/app/src/main/res/drawable/toolbar_divider_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..773fbc8 --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,36 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile.xml b/app/src/main/res/layout/activity_profile.xml new file mode 100644 index 0000000..12891b8 --- /dev/null +++ b/app/src/main/res/layout/activity_profile.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_profile_image_select.xml b/app/src/main/res/layout/dialog_profile_image_select.xml new file mode 100644 index 0000000..a34e39d --- /dev/null +++ b/app/src/main/res/layout/dialog_profile_image_select.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index ab23f44..6f6bb5e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -14,9 +14,12 @@ #333333 #828282 #A9A9A9 + #686868 #EBECEF + #D4D4D4 + #D4D4D4 #0047D0 @@ -25,4 +28,6 @@ #D4D4D4 + #FF3A3A + #FFF8F8 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09e4982..7f69461 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,5 +4,20 @@ 취소 확인 + 네트워크 연결 상태를 확인해주세요. + + + 시작하기 + 구글 계정으로 로그인 + 로그인에 실패하였습니다. + + + 이름 + 닉네임 + 성별 + 갤러리에서 선택 + 기본 이미지에서 선택 + 잘못된 접근입니다. + 회원가입에 실패했습니다. \ No newline at end of file diff --git a/app/src/main/res/values/styles_button.xml b/app/src/main/res/values/styles_button.xml index 1841097..97cfbd2 100644 --- a/app/src/main/res/values/styles_button.xml +++ b/app/src/main/res/values/styles_button.xml @@ -1,8 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/styles_dialog.xml b/app/src/main/res/values/styles_dialog.xml index 55a64cf..61c771f 100644 --- a/app/src/main/res/values/styles_dialog.xml +++ b/app/src/main/res/values/styles_dialog.xml @@ -27,6 +27,12 @@ 0dp 21dp 16dp + @android:color/transparent + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/styles_toolbar.xml b/app/src/main/res/values/styles_toolbar.xml new file mode 100644 index 0000000..d2d2534 --- /dev/null +++ b/app/src/main/res/values/styles_toolbar.xml @@ -0,0 +1,19 @@ + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index edf283a..7088d0b 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { plugins { id 'com.android.application' version '7.1.0' apply false id 'com.android.library' version '7.1.0' apply false - id 'org.jetbrains.kotlin.android' version '1.6.10' apply false + id 'org.jetbrains.kotlin.android' version '1.6.20' apply false } task clean(type: Delete) {