diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 98708dc..79e57af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/main/java/one/njk/celestidesk/data/RolesDataStore.kt b/app/src/main/java/one/njk/celestidesk/data/RolesDataStore.kt index 1667667..4d2d482 100644 --- a/app/src/main/java/one/njk/celestidesk/data/RolesDataStore.kt +++ b/app/src/main/java/one/njk/celestidesk/data/RolesDataStore.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.first +import one.njk.celestidesk.data.auth.model.TokenResponse enum class Role { EMPLOYEE, TEAM_LEAD, MANAGER @@ -15,6 +16,7 @@ class RolesDataStore(private val context: Context) { companion object { private val Context.rolesDataStore by preferencesDataStore(name = "roles") private val ROLE = stringPreferencesKey("ROLE") + private val TOKEN = stringPreferencesKey("TOKEN") } suspend fun setRole(role: Role) { @@ -27,4 +29,17 @@ class RolesDataStore(private val context: Context) { val role = context.rolesDataStore.data.first()[ROLE] return role?.let { Role.valueOf(it) } ?: Role.EMPLOYEE } + + suspend fun getToken(): TokenResponse { + return TokenResponse( + "local copy", + context.rolesDataStore.data.first()[TOKEN] ?: "" + ) + } + + suspend fun setToken(tokenResponse: TokenResponse) { + context.rolesDataStore.edit { + it[TOKEN] = tokenResponse.token + } + } } \ No newline at end of file diff --git a/app/src/main/java/one/njk/celestidesk/data/auth/AuthApi.kt b/app/src/main/java/one/njk/celestidesk/data/auth/AuthApi.kt new file mode 100644 index 0000000..47901a3 --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/data/auth/AuthApi.kt @@ -0,0 +1,23 @@ +package one.njk.celestidesk.data.auth + +import one.njk.celestidesk.data.auth.model.AuthLoginRequest +import one.njk.celestidesk.data.auth.model.AuthSignUpRequest +import one.njk.celestidesk.data.auth.model.TokenResponse +import retrofit2.http.Body +import retrofit2.http.POST + + +interface AuthApi { + + @POST("signup") + suspend fun signUp( + @Body request: AuthSignUpRequest + ): TokenResponse + + @POST("login") + suspend fun logIn( + @Body request: AuthLoginRequest + ): TokenResponse + + // TODO: Use interceptor for multiple authentication routes +} \ No newline at end of file diff --git a/app/src/main/java/one/njk/celestidesk/data/auth/AuthRepository.kt b/app/src/main/java/one/njk/celestidesk/data/auth/AuthRepository.kt new file mode 100644 index 0000000..600ea6c --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/data/auth/AuthRepository.kt @@ -0,0 +1,10 @@ +package one.njk.celestidesk.data.auth + +import one.njk.celestidesk.data.auth.model.AuthLoginRequest +import one.njk.celestidesk.data.auth.model.AuthResult +import one.njk.celestidesk.data.auth.model.AuthSignUpRequest + +interface AuthRepository { + suspend fun signUp(user: AuthSignUpRequest): AuthResult + suspend fun logIn(user: AuthLoginRequest): AuthResult +} \ No newline at end of file diff --git a/app/src/main/java/one/njk/celestidesk/data/auth/AuthRepositoryImpl.kt b/app/src/main/java/one/njk/celestidesk/data/auth/AuthRepositoryImpl.kt new file mode 100644 index 0000000..9bb5f8f --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/data/auth/AuthRepositoryImpl.kt @@ -0,0 +1,45 @@ +package one.njk.celestidesk.data.auth + +import android.util.Log +import one.njk.celestidesk.data.RolesDataStore +import one.njk.celestidesk.data.auth.model.AuthLoginRequest +import one.njk.celestidesk.data.auth.model.AuthResult +import one.njk.celestidesk.data.auth.model.AuthSignUpRequest +import retrofit2.HttpException + +class AuthRepositoryImpl( + private val api: AuthApi, + private val pref: RolesDataStore +): AuthRepository { + override suspend fun signUp(user: AuthSignUpRequest): AuthResult { + return try { + val response = api.signUp(user) + pref.setToken(response) + AuthResult.Authorized() + } catch (e: HttpException) { + Log.d("network", e.message.toString()) + + if(e.code() == 401) AuthResult.UnAuthorized() + else AuthResult.UnknownError() + } catch (e: Exception) { + AuthResult.UnknownError() + } + } + + override suspend fun logIn(user: AuthLoginRequest): AuthResult { + return try { + val response = api.logIn(user) + pref.setToken(response) + Log.d("network", response.message) + AuthResult.Authorized() + } catch (e: HttpException) { + Log.d("network", e.message.toString()) + + if(e.code() == 401) AuthResult.UnAuthorized() + else AuthResult.UnknownError() + } catch (e: Exception) { + Log.d("network", e.message.toString()) + AuthResult.UnknownError() + } + } +} diff --git a/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthLoginRequest.kt b/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthLoginRequest.kt new file mode 100644 index 0000000..aef78dd --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthLoginRequest.kt @@ -0,0 +1,6 @@ +package one.njk.celestidesk.data.auth.model + +data class AuthLoginRequest( + val username: String, + val password: String, +) diff --git a/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthResult.kt b/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthResult.kt new file mode 100644 index 0000000..97f78f5 --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthResult.kt @@ -0,0 +1,7 @@ +package one.njk.celestidesk.data.auth.model + +sealed class AuthResult (val data: T? = null){ + class Authorized(data: T? = null): AuthResult(data) + class UnAuthorized : AuthResult() + class UnknownError : AuthResult() +} diff --git a/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthSignUpRequest.kt b/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthSignUpRequest.kt new file mode 100644 index 0000000..3442ae5 --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/data/auth/model/AuthSignUpRequest.kt @@ -0,0 +1,11 @@ +package one.njk.celestidesk.data.auth.model + +import one.njk.celestidesk.data.Role + +data class AuthSignUpRequest( + val name: String, + val username: String, + val orgHandle: String, + val type: Role, + val password: String, +) \ No newline at end of file diff --git a/app/src/main/java/one/njk/celestidesk/data/auth/model/TokenResponse.kt b/app/src/main/java/one/njk/celestidesk/data/auth/model/TokenResponse.kt new file mode 100644 index 0000000..348869d --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/data/auth/model/TokenResponse.kt @@ -0,0 +1,6 @@ +package one.njk.celestidesk.data.auth.model + +data class TokenResponse ( + val message: String, + val token: String, +) \ No newline at end of file diff --git a/app/src/main/java/one/njk/celestidesk/di/AppModule.kt b/app/src/main/java/one/njk/celestidesk/di/AppModule.kt new file mode 100644 index 0000000..55a8e29 --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/di/AppModule.kt @@ -0,0 +1,45 @@ +package one.njk.celestidesk.di + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import one.njk.celestidesk.data.RolesDataStore +import one.njk.celestidesk.data.auth.AuthApi +import one.njk.celestidesk.data.auth.AuthRepository +import one.njk.celestidesk.data.auth.AuthRepositoryImpl +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.create +import javax.inject.Singleton + +const val BASE_URL = "https://celestidesk.onrender.com/api/employee/" + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideAuthApi(): AuthApi { + val moshi = Moshi + .Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create() + } + + @Provides + @Singleton + fun provideAuthRepository(api: AuthApi, pref: RolesDataStore): AuthRepository{ + return AuthRepositoryImpl(api, pref) + } + +} diff --git a/app/src/main/java/one/njk/celestidesk/ui/LoginFragment.kt b/app/src/main/java/one/njk/celestidesk/ui/LoginFragment.kt new file mode 100644 index 0000000..30be45d --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/ui/LoginFragment.kt @@ -0,0 +1,88 @@ +package one.njk.celestidesk.ui + +import android.content.Context +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import one.njk.celestidesk.data.auth.model.AuthResult +import one.njk.celestidesk.databinding.FragmentLoginBinding +import one.njk.celestidesk.viewmodels.AuthViewModel + +@AndroidEntryPoint +class LoginFragment: Fragment() { + lateinit var binding: FragmentLoginBinding + val viewModel: AuthViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentLoginBinding.inflate(inflater, container, false) + binding.apply { + submit.setOnClickListener { + lifecycleScope.launch { + val inputUsername = username.editText?.text.toString() + val inputPassword = password.editText?.text.toString() + if(inputPassword.isNotEmpty() && inputUsername.isNotEmpty()){ + viewModel.logIn(inputUsername, inputPassword) + } + } + } + viewModel.state.observe(viewLifecycleOwner){ + if(it.isLoading) { + loading.visibility = View.VISIBLE + submit.visibility = View.GONE + } else { + loading.visibility = View.INVISIBLE + submit.visibility = View.VISIBLE + } + if(it.authResult is AuthResult.Authorized) { + findNavController().navigate( + LoginFragmentDirections.actionLoginFragmentToRequestFragment()) + } else { + Toast.makeText(context, "verify your username / password", Toast.LENGTH_SHORT).show() + } + } + submit.setOnEditorActionListener { view, event, _ -> + if(event == EditorInfo.IME_ACTION_NEXT){ + view.requestFocus() + true + } else { + handleKeyEvent(view, event) + true + } + } + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + } + + override fun onDestroyView() { + super.onDestroyView() + } + private fun handleKeyEvent(view: View, keyCode: Int): Boolean { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // Hide the keyboard + val inputMethodManager = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + return true + } + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/one/njk/celestidesk/RequestFragment.kt b/app/src/main/java/one/njk/celestidesk/ui/RequestFragment.kt similarity index 98% rename from app/src/main/java/one/njk/celestidesk/RequestFragment.kt rename to app/src/main/java/one/njk/celestidesk/ui/RequestFragment.kt index 5fc6b11..4f5fe28 100644 --- a/app/src/main/java/one/njk/celestidesk/RequestFragment.kt +++ b/app/src/main/java/one/njk/celestidesk/ui/RequestFragment.kt @@ -1,4 +1,4 @@ -package one.njk.celestidesk +package one.njk.celestidesk.ui import android.os.Bundle import androidx.fragment.app.Fragment diff --git a/app/src/main/java/one/njk/celestidesk/viewmodels/AuthViewModel.kt b/app/src/main/java/one/njk/celestidesk/viewmodels/AuthViewModel.kt new file mode 100644 index 0000000..0a74dbc --- /dev/null +++ b/app/src/main/java/one/njk/celestidesk/viewmodels/AuthViewModel.kt @@ -0,0 +1,47 @@ +package one.njk.celestidesk.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.update +import one.njk.celestidesk.data.auth.AuthRepository +import one.njk.celestidesk.data.auth.model.AuthLoginRequest +import one.njk.celestidesk.data.auth.model.AuthResult +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val api: AuthRepository +) : ViewModel() { + + private val uiState = MutableStateFlow(UiState()) + + @OptIn(ExperimentalCoroutinesApi::class) + val state: LiveData = uiState.flatMapLatest { uiState -> + flowOf(uiState).flowOn(Dispatchers.Default) + }.asLiveData() + + suspend fun logIn(username: String, password: String){ + uiState.update { + it.copy(isLoading = true) + } + val result = api.logIn( + AuthLoginRequest(username, password) + ) + uiState.update { + it.copy(isLoading = false, authResult = result) + } + } +} + +data class UiState( + val isLoading: Boolean = false, + val authResult: AuthResult = AuthResult.UnknownError(), +) \ No newline at end of file diff --git a/app/src/main/res/drawable/profile.xml b/app/src/main/res/drawable/profile.xml new file mode 100644 index 0000000..a10aef7 --- /dev/null +++ b/app/src/main/res/drawable/profile.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml new file mode 100644 index 0000000..07d341d --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + +