Login & Register Screens
The two screens
Step 1: AuthActivity + nav_auth
Login and Register are two fragments inside a small AuthActivity with its own navigation graph. After login we open MainActivity with CLEAR_TASK, so pressing Back from Home closes the app instead of returning to the login screen.
package com.codingeasily.karteasy.ui.auth
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.codingeasily.karteasy.MainActivity
import com.codingeasily.karteasy.databinding.ActivityAuthBinding
/** Hosts LoginFragment and RegisterFragment (nav_auth.xml). */
class AuthActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityAuthBinding.inflate(layoutInflater).root)
}
/** Called by the fragments after a successful login or sign-up. */
fun openHome() {
startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK))
finish()
}
}<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/auth_nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/surface"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_auth" /><?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_auth"
app:startDestination="@id/loginFragment">
<fragment
android:id="@+id/loginFragment"
android:name="com.codingeasily.karteasy.ui.auth.LoginFragment"
android:label="@string/login">
<action
android:id="@+id/action_login_to_register"
app:destination="@id/registerFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
</fragment>
<fragment
android:id="@+id/registerFragment"
android:name="com.codingeasily.karteasy.ui.auth.RegisterFragment"
android:label="@string/create_account" />
</navigation><?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="250"
android:fromXDelta="100%"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:toXDelta="0" />Step 2: Validator
The same rules as the PHP API, written once and used by Login, Register, Address and Edit Profile:
package com.codingeasily.karteasy.util
import android.util.Patterns
/** Same rules as the PHP API, so the user sees errors before a network call. */
object Validator {
fun email(v: String) = Patterns.EMAIL_ADDRESS.matcher(v.trim()).matches()
fun phone(v: String) = Regex("^[6-9][0-9]{9}$").matches(v.trim())
fun pincode(v: String) = Regex("^[1-9][0-9]{5}$").matches(v.trim())
fun password(v: String) = v.length >= 6
fun name(v: String) = v.trim().length in 2..80
}Step 3: Login layout
TextInputLayout gives the floating label, start icon, error text under the field and — with endIconMode="password_toggle" — the eye button to show the password. autofillHints lets password managers fill the form.
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<ImageView
android:layout_width="88dp"
android:layout_height="88dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="32dp"
android:importantForAccessibility="no"
android:src="@mipmap/ic_launcher" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:text="@string/welcome_back"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="@color/text_primary"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="28dp"
android:text="@string/login_subtitle"
android:textColor="@color/text_secondary" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/email_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/email"
app:startIconDrawable="@drawable/ic_email">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/email_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="emailAddress"
android:imeOptions="actionNext"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/password"
app:endIconMode="password_toggle"
app:startIconDrawable="@drawable/ic_lock">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="password"
android:imeOptions="actionDone"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/login" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:indeterminate="true"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/demo_button"
style="@style/Widget.KartEasy.Button.Outlined"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/use_demo" />
<TextView
android:id="@+id/register_link"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="24dp"
android:background="?attr/selectableItemBackground"
android:padding="8dp"
android:text="@string/no_account"
android:textColor="@color/brand"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>Step 4: AuthViewModel
The ViewModel exposes one StateFlow. Because the state lives in the ViewModel, rotating the phone during login does not start a second request or lose the progress bar. A double tap is ignored while loading.
package com.codingeasily.karteasy.ui.auth
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.codingeasily.karteasy.data.api.Result
import com.codingeasily.karteasy.util.kartApp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AuthViewModel(app: Application) : AndroidViewModel(app) {
sealed class State {
object Idle : State()
object Loading : State()
object LoggedIn : State()
data class Failed(val message: String) : State()
}
private val repo = app.kartApp.repository
private val _state = MutableStateFlow<State>(State.Idle)
val state: StateFlow<State> = _state
fun login(email: String, password: String) = submit { repo.login(email, password) }
fun register(name: String, email: String, phone: String, password: String) =
submit { repo.register(name, email, phone, password) }
private fun submit(call: suspend () -> Result<*>) {
if (_state.value == State.Loading) return // ignore double taps
_state.value = State.Loading
viewModelScope.launch {
_state.value = when (val r = call()) {
is Result.Success -> State.LoggedIn
is Result.Error -> State.Failed(r.message)
}
}
}
fun consumeError() {
if (_state.value is State.Failed) _state.value = State.Idle
}
}// ShopRepository.kt – login saves the token as soon as it arrives
suspend fun login(email: String, password: String): Result<AuthData> {
val r = safeCall { api.login(LoginRequest(email.trim(), password)) }
if (r is Result.Success) session.saveLogin(r.data.token, r.data.user)
return r
}Step 5: LoginFragment
The fragment validates, calls vm.login() and draws whatever state arrives. collectWhenStarted (from Extensions.kt) collects the flow only while the screen is visible.
package com.codingeasily.karteasy.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.databinding.FragmentLoginBinding
import com.codingeasily.karteasy.util.Validator
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.snack
class LoginFragment : Fragment() {
private var _binding: FragmentLoginBinding? = null
private val binding get() = _binding!!
private val vm: AuthViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentLoginBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.loginButton.setOnClickListener { submit() }
binding.passwordInput.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) { submit(); true } else false
}
binding.registerLink.setOnClickListener { findNavController().navigate(R.id.action_login_to_register) }
binding.demoButton.setOnClickListener {
binding.emailInput.setText(getString(R.string.demo_email))
binding.passwordInput.setText(getString(R.string.demo_password))
}
collectWhenStarted(vm.state) { state ->
val loading = state is AuthViewModel.State.Loading
binding.loginButton.isEnabled = !loading
binding.progress.visibility = if (loading) View.VISIBLE else View.GONE
when (state) {
is AuthViewModel.State.LoggedIn -> (requireActivity() as AuthActivity).openHome()
is AuthViewModel.State.Failed -> { snack(state.message); vm.consumeError() }
else -> Unit
}
}
}
private fun submit() {
val email = binding.emailInput.text.toString()
val pass = binding.passwordInput.text.toString()
binding.emailLayout.error = if (Validator.email(email)) null else getString(R.string.err_email)
binding.passwordLayout.error = if (Validator.password(pass)) null else getString(R.string.err_password)
if (binding.emailLayout.error == null && binding.passwordLayout.error == null) vm.login(email, pass)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}Step 6: Register screen
Same pattern with more fields. The phone field shows a +91 prefix and a 10-digit counter; the user must accept the terms.
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/create_account"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="@color/text_primary"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/register_subtitle"
android:textColor="@color/text_secondary" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/name_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/full_name"
app:startIconDrawable="@drawable/ic_person">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/name_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="name"
android:inputType="textPersonName|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/email_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/email"
app:startIconDrawable="@drawable/ic_email">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/email_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="emailAddress"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/phone_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/mobile_optional"
app:counterEnabled="true"
app:counterMaxLength="10"
app:prefixText="+91 "
app:startIconDrawable="@drawable/ic_phone">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/phone_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="phone"
android:inputType="phone"
android:maxLength="10" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/password"
app:endIconMode="password_toggle"
app:startIconDrawable="@drawable/ic_lock">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="newPassword"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/confirm_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/confirm_password"
app:endIconMode="password_toggle"
app:startIconDrawable="@drawable/ic_lock">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/confirm_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/terms_check"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/accept_terms" />
<com.google.android.material.button.MaterialButton
android:id="@+id/register_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/sign_up" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/login_link"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:background="?attr/selectableItemBackground"
android:padding="8dp"
android:text="@string/have_account"
android:textColor="@color/brand"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>package com.codingeasily.karteasy.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.databinding.FragmentRegisterBinding
import com.codingeasily.karteasy.util.Validator
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.snack
class RegisterFragment : Fragment() {
private var _binding: FragmentRegisterBinding? = null
private val binding get() = _binding!!
private val vm: AuthViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentRegisterBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.registerButton.setOnClickListener { submit() }
binding.loginLink.setOnClickListener { findNavController().navigateUp() }
collectWhenStarted(vm.state) { state ->
val loading = state is AuthViewModel.State.Loading
binding.registerButton.isEnabled = !loading
binding.progress.visibility = if (loading) View.VISIBLE else View.GONE
when (state) {
is AuthViewModel.State.LoggedIn -> (requireActivity() as AuthActivity).openHome()
is AuthViewModel.State.Failed -> { snack(state.message); vm.consumeError() }
else -> Unit
}
}
}
private fun submit() {
val name = binding.nameInput.text.toString()
val email = binding.emailInput.text.toString()
val phone = binding.phoneInput.text.toString()
val pass = binding.passwordInput.text.toString()
val confirm = binding.confirmInput.text.toString()
binding.nameLayout.error = if (Validator.name(name)) null else getString(R.string.err_name)
binding.emailLayout.error = if (Validator.email(email)) null else getString(R.string.err_email)
binding.phoneLayout.error = if (phone.isEmpty() || Validator.phone(phone)) null else getString(R.string.err_phone)
binding.passwordLayout.error = if (Validator.password(pass)) null else getString(R.string.err_password)
binding.confirmLayout.error = if (pass == confirm) null else getString(R.string.err_password_match)
val ok = listOf(binding.nameLayout, binding.emailLayout, binding.phoneLayout, binding.passwordLayout, binding.confirmLayout)
.all { it.error == null }
if (!ok) return
if (!binding.termsCheck.isChecked) {
snack(getString(R.string.err_terms))
return
}
vm.register(name, email, phone, pass)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}What happens after login
Test cases
- 1Empty email → "Enter a valid email address" under the field, no network call
- 2Wrong password → Snackbar "Wrong email or password." from the API
- 3Use demo account → Login → Home opens
- 4Turn on airplane mode → "No internet connection…"
- 5Register with an existing email → "This email is already registered. Please login."
- 6Passwords different → "Passwords do not match"
- 7Rotate the phone while loading → still one request, progress stays
