회원가입, 로그인 페이지 추가

This commit is contained in:
2023-07-24 05:38:49 +09:00
parent c1054c5ede
commit d562e9199c
76 changed files with 2238 additions and 3 deletions

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.common
import retrofit2.Retrofit
class ApiBuilder {
fun <T> build(retrofit: Retrofit, service: Class<T>): T {
return retrofit.create(service)
}
}

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.common
import com.google.gson.annotations.SerializedName
data class ApiResponse<T>(
@SerializedName("success") val success: Boolean,
@SerializedName("data") val data: T? = null,
@SerializedName("message") val message: String? = null,
@SerializedName("errorProperty") val errorProperty: String? = null
)

View File

@@ -1,4 +1,12 @@
package kr.co.vividnext.sodalive.common
object Constants {
const val PREF_TOKEN = "pref_token"
const val PREF_EMAIL = "pref_email"
const val PREF_USER_ID = "pref_user_id"
const val PREF_NICKNAME = "pref_nickname"
const val PREF_PROFILE_IMAGE = "pref_profile_image"
const val EXTRA_DATA = "extra_data"
const val EXTRA_TERMS = "extra_terms"
}

View File

@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.common
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.AnimationDrawable
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import android.view.WindowManager
import androidx.appcompat.app.AlertDialog
import kr.co.vividnext.sodalive.databinding.DialogLoadingBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class LoadingDialog(
activity: Activity,
layoutInflater: LayoutInflater
) {
private val alertDialog: AlertDialog
private val dialogView = DialogLoadingBinding.inflate(layoutInflater)
private val animationDrawable: AnimationDrawable
init {
val dialogBuilder = AlertDialog.Builder(activity)
dialogBuilder.setView(dialogView.root)
animationDrawable = dialogView.tvLoading.compoundDrawables[1] as AnimationDrawable
alertDialog = dialogBuilder.create()
alertDialog.setCancelable(false)
alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
fun show(width: Int, message: String = "") {
alertDialog.show()
animationDrawable.start()
dialogView.tvLoading.text = message
val lp = WindowManager.LayoutParams()
lp.copyFrom(alertDialog.window?.attributes)
lp.width = width - (26.7f.dpToPx()).toInt()
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
alertDialog.window?.attributes = lp
}
fun dismiss() {
animationDrawable.stop()
alertDialog.dismiss()
}
}

View File

@@ -43,4 +43,34 @@ object SharedPreferenceManager {
else -> throw UnsupportedOperationException("Error")
}
}
var token: String
get() = sharedPreferences[Constants.PREF_TOKEN, ""]
set(value) {
sharedPreferences[Constants.PREF_TOKEN] = value
}
var userId: Long
get() = sharedPreferences[Constants.PREF_USER_ID, 0]
set(value) {
sharedPreferences[Constants.PREF_USER_ID] = value
}
var nickname: String
get() = sharedPreferences[Constants.PREF_NICKNAME, ""]
set(value) {
sharedPreferences[Constants.PREF_NICKNAME] = value
}
var email: String
get() = sharedPreferences[Constants.PREF_EMAIL, ""]
set(value) {
sharedPreferences[Constants.PREF_EMAIL] = value
}
var profileImage: String
get() = sharedPreferences[Constants.PREF_PROFILE_IMAGE, ""]
set(value) {
sharedPreferences[Constants.PREF_PROFILE_IMAGE] = value
}
}

View File

@@ -3,10 +3,20 @@ package kr.co.vividnext.sodalive.di
import android.content.Context
import com.google.gson.GsonBuilder
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.common.ApiBuilder
import kr.co.vividnext.sodalive.network.TokenAuthenticator
import kr.co.vividnext.sodalive.settings.TermsApi
import kr.co.vividnext.sodalive.settings.TermsRepository
import kr.co.vividnext.sodalive.settings.TermsViewModel
import kr.co.vividnext.sodalive.user.UserApi
import kr.co.vividnext.sodalive.user.UserRepository
import kr.co.vividnext.sodalive.user.find_password.FindPasswordViewModel
import kr.co.vividnext.sodalive.user.login.LoginViewModel
import kr.co.vividnext.sodalive.user.signup.SignUpViewModel
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.dsl.module
import retrofit2.Retrofit
@@ -44,11 +54,22 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
.client(get())
.build()
}
single { ApiBuilder().build(get(), UserApi::class.java) }
single { ApiBuilder().build(get(), TermsApi::class.java) }
}
private val viewModelModule = module {}
private val viewModelModule = module {
viewModel { LoginViewModel(get()) }
viewModel { SignUpViewModel(get()) }
viewModel { TermsViewModel(get()) }
viewModel { FindPasswordViewModel(get()) }
}
private val repositoryModule = module {}
private val repositoryModule = module {
factory { UserRepository(get()) }
factory { TermsRepository(get()) }
}
private val moduleList = listOf(
networkModule,

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.extensions
import android.content.res.Resources
import android.util.DisplayMetrics
import java.text.DecimalFormat
fun Float.dpToPx(): Float {
val metrics = Resources.getSystem().displayMetrics
return this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)
}
fun Int.dpToPx(): Float {
val metrics = Resources.getSystem().displayMetrics
return this.toFloat() * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)
}
fun Int.moneyFormat(): String = DecimalFormat("###,###").format(this)
fun Long.moneyFormat(): String = DecimalFormat("###,###").format(this)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.settings
import com.google.gson.annotations.SerializedName
data class GetTermsResponse(
@SerializedName("title") val title: String,
@SerializedName("description") val description: String
)

View File

@@ -0,0 +1,84 @@
package kr.co.vividnext.sodalive.settings
import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.Toast
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityTermsBinding
import org.koin.android.ext.android.inject
class TermsActivity : BaseActivity<ActivityTermsBinding>(ActivityTermsBinding::inflate) {
private val viewModel: TermsViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
val terms = intent.getStringExtra(Constants.EXTRA_TERMS) ?: "terms"
if (terms == "privacy") {
viewModel.getPrivacyPolicy()
} else {
viewModel.getTermsOfService()
}
}
@SuppressLint("SetJavaScriptEnabled")
override fun setupView() {
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
WebSettingsCompat.setForceDark(
binding.webView.settings,
WebSettingsCompat.FORCE_DARK_ON
)
}
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.webView.settings.apply {
javaScriptEnabled = false // 자바스크립트 실행 허용
javaScriptCanOpenWindowsAutomatically = false // 자바스크립트에서 새창 실 행 허용
setSupportMultipleWindows(false) // 새 창 실행 허용
loadWithOverviewMode = true // 메타 태그 허용
useWideViewPort = true // 화면 사이즈 맞추기 허용
setSupportZoom(false) // 화면 줌 허용
builtInZoomControls = false // 화면 확대 축소 허용 여부
}
}
private fun bindData() {
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "공지사항을 불러오고 있습니다.")
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.titleLiveData.observe(this) {
binding.toolbar.tvBack.text = it
}
viewModel.termsLiveData.observe(this) {
val viewPort =
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=0.8\">"
val data = viewPort + it
binding.webView.loadData(
data,
"text/html; charset=utf-8",
"utf-8"
)
}
}
}

View File

@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.settings
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
interface TermsApi {
@GET("/stplat/terms_of_service")
fun getTermsOfService(): Single<ApiResponse<GetTermsResponse>>
@GET("/stplat/privacy_policy")
fun getPrivacyPolicy(): Single<ApiResponse<GetTermsResponse>>
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.settings
class TermsRepository(private val api: TermsApi) {
fun getTermsOfService() = api.getTermsOfService()
fun getPrivacyPolicy() = api.getPrivacyPolicy()
}

View File

@@ -0,0 +1,92 @@
package kr.co.vividnext.sodalive.settings
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
class TermsViewModel(private val repository: TermsRepository) : BaseViewModel() {
private val _titleLiveData = MutableLiveData<String>()
val titleLiveData: LiveData<String>
get() = _titleLiveData
private val _termsLiveData = MutableLiveData<String>()
val termsLiveData: LiveData<String>
get() = _termsLiveData
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
fun getTermsOfService() {
_isLoading.value = true
compositeDisposable.add(
repository.getTermsOfService()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
_titleLiveData.postValue(it.data.title)
_termsLiveData.postValue(it.data.description)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun getPrivacyPolicy() {
_isLoading.value = true
compositeDisposable.add(
repository.getPrivacyPolicy()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
_titleLiveData.postValue(it.data.title)
_termsLiveData.postValue(it.data.description)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@@ -6,8 +6,10 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivitySplashBinding
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.user.login.LoginActivity
@SuppressLint("CustomSplashScreen")
class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding::inflate) {
@@ -17,6 +19,14 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (SharedPreferenceManager.token.isBlank()) {
showLoginActivity()
} else {
showMainActivity()
}
}
private fun showMainActivity() {
handler.postDelayed({
startActivity(
Intent(applicationContext, MainActivity::class.java).apply {
@@ -28,5 +38,17 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding
}, 500)
}
private fun showLoginActivity() {
handler.postDelayed({
startActivity(
Intent(applicationContext, LoginActivity::class.java).apply {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
finish()
}, 500)
}
override fun setupView() {}
}

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.user
import com.google.gson.annotations.SerializedName
enum class Gender {
@SerializedName("MALE")
MALE,
@SerializedName("FEMALE")
FEMALE,
@SerializedName("NONE")
NONE
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.user
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.user.find_password.ForgotPasswordRequest
import kr.co.vividnext.sodalive.user.login.LoginRequest
import kr.co.vividnext.sodalive.user.login.LoginResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
interface UserApi {
@POST("/member/login")
fun login(@Body request: LoginRequest): Single<ApiResponse<LoginResponse>>
@POST("/member/signup")
@Multipart
fun signUp(
@Part profileImage: MultipartBody.Part?,
@Part("request") request: RequestBody
): Single<ApiResponse<LoginResponse>>
@POST("/member/forgot-password")
fun findPassword(@Body request: ForgotPasswordRequest): Single<ApiResponse<Any>>
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.user
import kr.co.vividnext.sodalive.user.find_password.ForgotPasswordRequest
import kr.co.vividnext.sodalive.user.login.LoginRequest
import okhttp3.MultipartBody
import okhttp3.RequestBody
class UserRepository(private val userApi: UserApi) {
fun login(request: LoginRequest) = userApi.login(request)
fun signUp(profileImage: MultipartBody.Part?, request: RequestBody) = userApi.signUp(
profileImage,
request
)
fun findPassword(request: ForgotPasswordRequest) = userApi.findPassword(request = request)
}

View File

@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.user.find_password
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import com.jakewharton.rxbinding4.widget.textChanges
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityFindPasswordBinding
import org.koin.android.ext.android.inject
class FindPasswordActivity : BaseActivity<ActivityFindPasswordBinding>(
ActivityFindPasswordBinding::inflate
) {
private val viewModel: FindPasswordViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = "비밀번호 재설정"
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.tvServiceCenter.setOnClickListener {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("http://pf.kakao.com/_sZaeb")
)
)
}
binding.tvFindPassword.setOnClickListener { viewModel.findPassword { finish() } }
}
private fun bindData() {
compositeDisposable.add(
binding.etEmail.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.email = it.toString()
}
)
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
}
}

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.user.find_password
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.user.UserRepository
class FindPasswordViewModel(private val repository: UserRepository) : BaseViewModel() {
var email = ""
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
fun findPassword(onSuccess: () -> Unit) {
if (email.isBlank()) {
_toastLiveData.postValue("이메일을 입력하세요.")
return
}
_isLoading.value = true
val request = ForgotPasswordRequest(email = email)
compositeDisposable.add(
repository.findPassword(request)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success) {
_toastLiveData.postValue(
"임시 비밀번호가 입력하신 이메일로 발송되었습니다. 이메일을 확인해 주세요."
)
onSuccess()
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.user.find_password
import com.google.gson.annotations.SerializedName
data class ForgotPasswordRequest(@SerializedName("email") val email: String)

View File

@@ -0,0 +1,123 @@
package kr.co.vividnext.sodalive.user.login
import android.content.Intent
import android.os.Bundle
import android.text.InputType
import android.widget.Toast
import com.jakewharton.rxbinding4.widget.textChanges
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityLoginBinding
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.user.find_password.FindPasswordActivity
import kr.co.vividnext.sodalive.user.signup.SignUpActivity
import org.koin.android.ext.android.inject
class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::inflate) {
private val viewModel: LoginViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
}
override fun setupView() {
binding.tvToolbar.text = "로그인"
loadingDialog = LoadingDialog(this, layoutInflater)
binding.tvLogin.setOnClickListener {
viewModel.login {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
finishAffinity()
val nextIntent = Intent(applicationContext, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(nextIntent)
}
}
binding.tvSignUp.setOnClickListener {
val nextIntent = Intent(applicationContext, SignUpActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
startActivity(nextIntent)
}
binding.tvForgotPassword.setOnClickListener {
startActivity(
Intent(
applicationContext,
FindPasswordActivity::class.java
)
)
}
binding.tvVisiblePassword.setOnClickListener { viewModel.onClickVisiblePassword() }
}
private fun bindData() {
compositeDisposable.add(
binding.etEmail.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.email = it.toString()
}
)
compositeDisposable.add(
binding.etPassword.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.password = it.toString()
}
)
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.visiblePasswordLiveData.observe(this) {
binding.tvVisiblePassword.isSelected = it
if (it) {
binding.etPassword.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
} else {
binding.etPassword.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
}
}
}
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.user.login
import com.google.gson.annotations.SerializedName
data class LoginRequest(
@SerializedName("email") val email: String,
@SerializedName("password") val password: String
)

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.user.login
import com.google.gson.annotations.SerializedName
data class LoginResponse(
@SerializedName("userId") val userId: Long,
@SerializedName("token") val token: String,
@SerializedName("nickname") val nickname: String,
@SerializedName("email") val email: String,
@SerializedName("profileImage") val profileImage: String
)

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.user.login
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.user.UserRepository
class LoginViewModel(private val repository: UserRepository) : BaseViewModel() {
var email = ""
var password = ""
private val _visiblePasswordLiveData = MutableLiveData(false)
val visiblePasswordLiveData: LiveData<Boolean>
get() = _visiblePasswordLiveData
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
fun login(onSuccess: (String?) -> Unit) {
if (email.isBlank()) {
_toastLiveData.postValue("이메일을 입력하세요.")
return
}
if (password.isBlank()) {
_toastLiveData.postValue("비밃번호를 입력하세요.")
return
}
_isLoading.value = true
val request = LoginRequest(email, password)
compositeDisposable.add(
repository.login(request)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
SharedPreferenceManager.token = it.data.token
SharedPreferenceManager.email = it.data.email
SharedPreferenceManager.userId = it.data.userId
SharedPreferenceManager.nickname = it.data.nickname
SharedPreferenceManager.profileImage = it.data.profileImage
onSuccess(it.message)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun onClickVisiblePassword() {
_visiblePasswordLiveData.postValue(!_visiblePasswordLiveData.value!!)
}
}

View File

@@ -0,0 +1,254 @@
package kr.co.vividnext.sodalive.user.signup
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import coil.load
import coil.transform.RoundedCornersTransformation
import com.github.dhaval2404.imagepicker.ImagePicker
import com.jakewharton.rxbinding4.widget.textChanges
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.RealPathUtil
import kr.co.vividnext.sodalive.databinding.ActivitySignupBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.settings.TermsActivity
import kr.co.vividnext.sodalive.user.Gender
import org.koin.android.ext.android.inject
class SignUpActivity : BaseActivity<ActivitySignupBinding>(ActivitySignupBinding::inflate) {
private val viewModel: SignUpViewModel by inject()
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onClickBackButton()
}
}
private lateinit var loadingDialog: LoadingDialog
private val imageResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == RESULT_OK) {
// Image Uri will not be null for RESULT_OK
val fileUri = data?.data!!
binding.ivProfile.background = null
binding.ivProfile.load(fileUri) {
crossfade(true)
transformations(RoundedCornersTransformation(13.3f.dpToPx()))
}
viewModel.profileImageUri = fileUri
} else if (resultCode == ImagePicker.RESULT_ERROR) {
Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
viewModel.getRealPathFromURI = {
RealPathUtil.getRealPath(applicationContext, it)
}
bindData()
}
override fun setupView() {
binding.toolbar.tvBack.text = "회원가입"
binding.toolbar.tvBack.setOnClickListener { onClickBackButton() }
loadingDialog = LoadingDialog(this, layoutInflater)
binding.ivPhotoPicker.setOnClickListener {
ImagePicker.with(this)
.crop()
.galleryOnly()
.galleryMimeTypes( // Exclude gif images
mimeTypes = arrayOf(
"image/png",
"image/jpg",
"image/jpeg"
)
)
.createIntent { imageResult.launch(it) }
}
binding.tvMale.setOnClickListener {
viewModel.changeGender(Gender.MALE)
}
binding.tvFemale.setOnClickListener {
viewModel.changeGender(Gender.FEMALE)
}
binding.tvNone.setOnClickListener {
viewModel.changeGender(Gender.NONE)
}
binding.tvTermsOfService.setOnClickListener {
val intent = Intent(applicationContext, TermsActivity::class.java)
intent.putExtra("terms", "terms")
startActivity(intent)
}
binding.tvPrivacyPolicy.setOnClickListener {
val intent = Intent(applicationContext, TermsActivity::class.java)
intent.putExtra("terms", "privacy")
startActivity(intent)
}
binding.ivTermsOfService.setOnClickListener {
viewModel.onClickCheckboxTermsOfService()
}
binding.ivPrivacyPolicy.setOnClickListener {
viewModel.onClickCheckboxPrivacyPolicy()
}
binding.tvSignUp.setOnClickListener {
viewModel.signUp {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
finishAffinity()
val nextIntent = Intent(applicationContext, MainActivity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras
} else {
null
}
if (extras != null) {
nextIntent.putExtra(Constants.EXTRA_DATA, extras)
}
startActivity(nextIntent)
}
}
}
private fun bindData() {
compositeDisposable.add(
binding.etEmail.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.email = it.toString()
}
)
compositeDisposable.add(
binding.etPassword.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.password = it.toString()
}
)
compositeDisposable.add(
binding.etPasswordRe.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.passwordRe = it.toString()
}
)
compositeDisposable.add(
binding.etNickname.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.nickname = it.toString()
}
)
viewModel.isAgreeTermsOfServiceLiveData.observe(this) {
binding.ivTermsOfService.isSelected = it
}
viewModel.isAgreePrivacyPolicyLiveData.observe(this) {
binding.ivPrivacyPolicy.isSelected = it
}
viewModel.genderLiveData.observe(this) {
binding.tvMale.isSelected = false
binding.tvFemale.isSelected = false
binding.tvNone.isSelected = false
when (it) {
Gender.MALE -> binding.tvMale.isSelected = true
Gender.FEMALE -> binding.tvFemale.isSelected = true
Gender.NONE -> binding.tvNone.isSelected = true
else -> {
}
}
}
viewModel.signUpErrorLiveData.observe(this) {
Toast.makeText(applicationContext, it.message, Toast.LENGTH_LONG).show()
when (it.errorProperty) {
"email" -> {
viewModel.setStep(step = SignUpViewModel.EmailSignUpStep.STEP_1)
binding.etEmail.error = it.message
binding.etEmail.requestFocus()
}
"password" -> {
viewModel.setStep(step = SignUpViewModel.EmailSignUpStep.STEP_1)
binding.etPassword.error = it.message
binding.etPassword.requestFocus()
}
"nickname" -> {
binding.etNickname.error = it.message
binding.etNickname.requestFocus()
}
}
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.stepLiveData.observe(this) {
if (it == SignUpViewModel.EmailSignUpStep.STEP_2) {
binding.toolbar.tvBack.text = "프로필 설정"
binding.tvSignUp.text = "회원가입"
binding.llStep1.visibility = View.GONE
binding.llStep2.visibility = View.VISIBLE
} else {
binding.toolbar.tvBack.text = "회원가입"
binding.tvSignUp.text = "다음"
binding.llStep1.visibility = View.VISIBLE
binding.llStep2.visibility = View.GONE
}
}
}
private fun onClickBackButton() {
if (viewModel.stepLiveData.value!! == SignUpViewModel.EmailSignUpStep.STEP_2) {
viewModel.setStep(SignUpViewModel.EmailSignUpStep.STEP_1)
} else {
finish()
}
}
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.user.signup
import com.google.gson.annotations.SerializedName
data class SignUpError(
@SerializedName("errorProperty") val errorProperty: String,
@SerializedName("message") val message: String
)

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.user.signup
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.user.Gender
data class SignUpRequest(
@SerializedName("email") val email: String,
@SerializedName("password") val password: String,
@SerializedName("nickname") val nickname: String,
@SerializedName("gender") val gender: Gender,
@SerializedName("isAgreeTermsOfService") val isAgreeTermsOfService: Boolean,
@SerializedName("isAgreePrivacyPolicy") val isAgreePrivacyPolicy: Boolean,
@SerializedName("container") val container: String = "aos"
)

View File

@@ -0,0 +1,241 @@
package kr.co.vividnext.sodalive.user.signup
import android.net.Uri
import androidx.core.util.PatternsCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.user.Gender
import kr.co.vividnext.sodalive.user.UserRepository
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
class SignUpViewModel(private val repository: UserRepository) : BaseViewModel() {
enum class EmailSignUpStep {
@SerializedName("STEP_1") STEP_1,
@SerializedName("STEP_2") STEP_2
}
var email = ""
var password = ""
var passwordRe = ""
var nickname = ""
var profileImageUri: Uri? = null
private val _genderLiveData = MutableLiveData(Gender.NONE)
val genderLiveData: LiveData<Gender>
get() = _genderLiveData
private val _isAgreeTermsOfServiceLiveData = MutableLiveData(false)
val isAgreeTermsOfServiceLiveData: LiveData<Boolean>
get() = _isAgreeTermsOfServiceLiveData
private val _isAgreePrivacyPolicyLiveData = MutableLiveData(false)
val isAgreePrivacyPolicyLiveData: LiveData<Boolean>
get() = _isAgreePrivacyPolicyLiveData
private val _signUpErrorLiveData = MutableLiveData<SignUpError>()
val signUpErrorLiveData: LiveData<SignUpError>
get() = _signUpErrorLiveData
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _stepLiveData = MutableLiveData(EmailSignUpStep.STEP_1)
val stepLiveData: LiveData<EmailSignUpStep>
get() = _stepLiveData
lateinit var getRealPathFromURI: (Uri) -> String?
fun setStep(step: EmailSignUpStep = EmailSignUpStep.STEP_2) {
_stepLiveData.postValue(step)
}
fun signUp(onSuccess: (String?) -> Unit) {
if (stepLiveData.value!! == EmailSignUpStep.STEP_1) {
if (validationStep1()) return
setStep()
return
}
if (validationStep2()) return
val request = SignUpRequest(
email = email,
password = password,
nickname = nickname,
gender = _genderLiveData.value!!,
isAgreeTermsOfService = _isAgreeTermsOfServiceLiveData.value!!,
isAgreePrivacyPolicy = _isAgreePrivacyPolicyLiveData.value!!
)
val requestJson = Gson().toJson(request)
val profileImage = if (profileImageUri != null) {
val file = File(getRealPathFromURI(profileImageUri!!))
MultipartBody.Part.createFormData(
"profileImage",
file.name,
file.asRequestBody("image/*".toMediaType())
)
} else {
null
}
_isLoading.value = true
compositeDisposable.add(
repository.signUp(
profileImage,
requestJson.toRequestBody("text/plain".toMediaType())
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
SharedPreferenceManager.token = it.data.token
SharedPreferenceManager.email = it.data.email
SharedPreferenceManager.userId = it.data.userId
SharedPreferenceManager.nickname = it.data.nickname
SharedPreferenceManager.profileImage = it.data.profileImage
_isLoading.value = false
onSuccess(it.message)
} else {
_isLoading.value = false
if (it.errorProperty != null && it.message != null) {
_signUpErrorLiveData.postValue(
SignUpError(it.errorProperty, it.message)
)
} else if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
}
},
{
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
private fun validationStep1(): Boolean {
if (email.isBlank()) {
_signUpErrorLiveData.postValue(
SignUpError(
"email",
"이메일을 입력하세요."
)
)
return true
}
if (password.isBlank()) {
_signUpErrorLiveData.postValue(
SignUpError(
"password",
"비밀번호를 입력하세요."
)
)
return true
}
if (password != passwordRe) {
_signUpErrorLiveData.postValue(
SignUpError(
"password",
"비밀번호가 일치하지 않습니다."
)
)
return true
}
if (
!_isAgreePrivacyPolicyLiveData.value!! ||
!_isAgreeTermsOfServiceLiveData.value!!
) {
_signUpErrorLiveData.postValue(
SignUpError(
"",
"약관에 동의하셔야 회원가입이 가능합니다."
)
)
return true
}
if (!PatternsCompat.EMAIL_ADDRESS.matcher(email).matches()) {
_signUpErrorLiveData.postValue(
SignUpError(
"email",
"올바른 이메일을 입력해 주세요"
)
)
return true
}
if (
"^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d$@!%*#?&]{8,}$"
.toRegex()
.matches(password)
.not()
) {
_signUpErrorLiveData.postValue(
SignUpError(
"password",
"영문, 숫자 포함 8자 이상의 비밀번호를 입력해 주세요."
)
)
}
return false
}
private fun validationStep2(): Boolean {
if (nickname.isBlank() || nickname.length < 2) {
_signUpErrorLiveData.postValue(
SignUpError(
"nickname",
"닉네임은 2자 이상 입력해 주세요."
)
)
return true
}
return false
}
fun onClickCheckboxTermsOfService() {
_isAgreeTermsOfServiceLiveData.postValue(!_isAgreeTermsOfServiceLiveData.value!!)
}
fun onClickCheckboxPrivacyPolicy() {
_isAgreePrivacyPolicyLiveData.postValue(!_isAgreePrivacyPolicyLiveData.value!!)
}
fun changeGender(gender: Gender) {
_genderLiveData.postValue(gender)
}
}