From 9a2917f9a2ff856c5fa5e8d8c14816fe80d6cc63 Mon Sep 17 00:00:00 2001 From: Abhishek Saxena <5abhisheksaxena@gmail.com> Date: Tue, 31 Jan 2023 22:41:53 +0530 Subject: [PATCH] Migrate GeneratePasswordActivity to compose. (#314) * Move password generation logic to ViewModel. * Store GeneratePassword state in GeneratePasswordViewState. * Update GeneratePasswordState when form is updated. * Remove password StateFlow nad use the one in the ViewState. * Fix dependency version incompatibility issue and add missing dependencies. * Add mdc3Theme adapter. * Add GeneratePasswordContent form. * Integrate GeneratePasswordContent. * Fix spotless issues. * Remove xml layout file. --- keypasscompose/build.gradle | 11 +- .../ui/generate/GeneratePasswordActivity.kt | 44 +--- .../ui/generate/GeneratePasswordViewModel.kt | 63 +++++ .../ui/generate/GeneratePasswordViewState.kt | 21 ++ .../ui/generate/ui/GeneratePasswordContent.kt | 235 ++++++++++++++++++ .../ui/generate/ui/GeneratePasswordScreen.kt | 43 ++++ .../ui/components/CheckboxWithLabel.kt | 32 +++ .../ui/generate/ui/utils/ClipboardUtils.kt | 17 ++ .../res/layout/activity_generate_password.xml | 104 -------- 9 files changed, 431 insertions(+), 139 deletions(-) create mode 100644 keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewModel.kt create mode 100644 keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewState.kt create mode 100644 keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordContent.kt create mode 100644 keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordScreen.kt create mode 100644 keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/components/CheckboxWithLabel.kt create mode 100644 keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/utils/ClipboardUtils.kt delete mode 100644 keypasscompose/src/main/res/layout/activity_generate_password.xml diff --git a/keypasscompose/build.gradle b/keypasscompose/build.gradle index fe821900..8a5c7e8f 100644 --- a/keypasscompose/build.gradle +++ b/keypasscompose/build.gradle @@ -36,6 +36,10 @@ android { kotlinOptions { jvmTarget = '1.8' useIR = true + + freeCompilerArgs += [ + '-Xopt-in=androidx.compose.material3.ExperimentalMaterial3Api' + ] } buildFeatures { compose true @@ -84,13 +88,18 @@ dependencies { api project(":common") implementation "androidx.compose.ui:ui:1.3.3" - implementation "androidx.compose.material:material:1.3.1" + implementation "androidx.compose.material:material:1.4.0-alpha04" implementation "androidx.compose.ui:ui-tooling-preview:1.3.3" implementation 'androidx.activity:activity-compose:1.5.1' + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1" + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha03" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" + implementation "androidx.compose.material:material-icons-extended:1.3.1" androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.3.3" debugImplementation "androidx.compose.ui:ui-tooling:1.3.3" implementation 'androidx.compose.material3:material3:1.1.0-alpha04' + implementation "com.google.accompanist:accompanist-themeadapter-material3:0.28.0" // XML Libraries diff --git a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordActivity.kt b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordActivity.kt index 3b0be14c..7db97dc4 100644 --- a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordActivity.kt +++ b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordActivity.kt @@ -1,47 +1,23 @@ package com.yogeshpaliyal.keypasscompose.ui.generate -import android.content.ClipData -import android.content.ClipboardManager import android.os.Bundle -import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import com.yogeshpaliyal.common.utils.PasswordGenerator -import com.yogeshpaliyal.keypasscompose.R -import com.yogeshpaliyal.keypasscompose.databinding.ActivityGeneratePasswordBinding +import com.google.accompanist.themeadapter.material3.Mdc3Theme +import com.yogeshpaliyal.keypasscompose.ui.generate.ui.GeneratePasswordScreen import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class GeneratePasswordActivity : AppCompatActivity() { - private lateinit var binding: ActivityGeneratePasswordBinding + private val viewModel: GeneratePasswordViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityGeneratePasswordBinding.inflate(layoutInflater) - setContentView(binding.root) - - generatePassword() - - binding.btnRefresh.setOnClickListener { - generatePassword() + setContent { + Mdc3Theme { + GeneratePasswordScreen(viewModel) + } } - - binding.tilPassword.setEndIconOnClickListener { - val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("random_password", binding.etPassword.text) - clipboard.setPrimaryClip(clip) - Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() - } - } - - private fun generatePassword() { - val password = PasswordGenerator( - length = binding.sliderPasswordLength.value.toInt(), - includeUpperCaseLetters = binding.cbCapAlphabets.isChecked, - includeLowerCaseLetters = binding.cbLowerAlphabets.isChecked, - includeSymbols = binding.cbSymbols.isChecked, - includeNumbers = binding.cbNumbers.isChecked - ).generatePassword() - - binding.etPassword.setText(password) - binding.etPassword.setSelection(password.length) } } diff --git a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewModel.kt b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewModel.kt new file mode 100644 index 00000000..6b11a5a6 --- /dev/null +++ b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewModel.kt @@ -0,0 +1,63 @@ +package com.yogeshpaliyal.keypasscompose.ui.generate + +import androidx.lifecycle.ViewModel +import com.yogeshpaliyal.common.utils.PasswordGenerator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class GeneratePasswordViewModel @Inject constructor() : ViewModel() { + + private val _viewState = MutableStateFlow(GeneratePasswordViewState.Initial) + val viewState = _viewState.asStateFlow() + + fun generatePassword() { + val currentViewState = _viewState.value + + val passwordGenerator = PasswordGenerator( + length = currentViewState.length, + includeUpperCaseLetters = currentViewState.includeUppercaseLetters, + includeLowerCaseLetters = currentViewState.includeLowercaseLetters, + includeSymbols = currentViewState.includeSymbols, + includeNumbers = currentViewState.includeNumbers, + ) + + _viewState.update { + val newPassword = passwordGenerator.generatePassword() + it.copy(password = newPassword) + } + } + + fun onPasswordLengthSliderChange(value: Float) { + _viewState.update { + it.copy(length = value.toInt()) + } + } + + fun onUppercaseCheckedChange(checked: Boolean) { + _viewState.update { + it.copy(includeUppercaseLetters = checked) + } + } + + fun onLowercaseCheckedChange(checked: Boolean) { + _viewState.update { + it.copy(includeLowercaseLetters = checked) + } + } + + fun onNumbersCheckedChange(checked: Boolean) { + _viewState.update { + it.copy(includeNumbers = checked) + } + } + + fun onSymbolsCheckedChange(checked: Boolean) { + _viewState.update { + it.copy(includeSymbols = checked) + } + } +} diff --git a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewState.kt b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewState.kt new file mode 100644 index 00000000..8227aefb --- /dev/null +++ b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/GeneratePasswordViewState.kt @@ -0,0 +1,21 @@ +package com.yogeshpaliyal.keypasscompose.ui.generate + +data class GeneratePasswordViewState( + val length: Int, + val includeUppercaseLetters: Boolean, + val includeLowercaseLetters: Boolean, + val includeSymbols: Boolean, + val includeNumbers: Boolean, + val password: String, +) { + companion object { + val Initial = GeneratePasswordViewState( + length = 10, + includeUppercaseLetters = true, + includeLowercaseLetters = true, + includeSymbols = true, + includeNumbers = true, + password = "" + ) + } +} diff --git a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordContent.kt b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordContent.kt new file mode 100644 index 00000000..83f2da83 --- /dev/null +++ b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordContent.kt @@ -0,0 +1,235 @@ +package com.yogeshpaliyal.keypasscompose.ui.generate.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.accompanist.themeadapter.material3.Mdc3Theme +import com.yogeshpaliyal.keypasscompose.ui.generate.GeneratePasswordViewState +import com.yogeshpaliyal.keypasscompose.ui.generate.ui.components.CheckboxWithLabel + +@Composable +fun GeneratePasswordContent( + viewState: GeneratePasswordViewState, + onCopyPasswordClick: () -> Unit, + onGeneratePasswordClick: () -> Unit, + onPasswordLengthChange: (Float) -> Unit, + onUppercaseCheckedChange: (Boolean) -> Unit, + onLowercaseCheckedChange: (Boolean) -> Unit, + onNumbersCheckedChange: (Boolean) -> Unit, + onSymbolsCheckedChange: (Boolean) -> Unit, +) { + Scaffold( + floatingActionButton = { GeneratePasswordFab(onGeneratePasswordClick) } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(Color(0xFFEEF0F2)) + .padding(16.dp) + ) { + FormInputCard( + viewState = viewState, + onCopyPasswordClick = onCopyPasswordClick, + onPasswordLengthChange = onPasswordLengthChange, + onUppercaseCheckedChange = onUppercaseCheckedChange, + onLowercaseCheckedChange = onLowercaseCheckedChange, + onNumbersCheckedChange = onNumbersCheckedChange, + onSymbolsCheckedChange = onSymbolsCheckedChange + ) + } + } +} + +@Composable +private fun GeneratePasswordFab(onGeneratePasswordClick: () -> Unit) { + FloatingActionButton( + onClick = onGeneratePasswordClick, + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "" + ) + } +} + +@Composable +private fun FormInputCard( + viewState: GeneratePasswordViewState, + onCopyPasswordClick: () -> Unit, + onPasswordLengthChange: (Float) -> Unit, + onUppercaseCheckedChange: (Boolean) -> Unit, + onLowercaseCheckedChange: (Boolean) -> Unit, + onNumbersCheckedChange: (Boolean) -> Unit, + onSymbolsCheckedChange: (Boolean) -> Unit +) { + OutlinedCard( + colors = CardDefaults.outlinedCardColors( + containerColor = Color.White + ), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + PasswordTextField(viewState.password, onCopyPasswordClick) + + // temporary label until we put slider label on the thumb to display current value. + PasswordLengthInput(viewState.length, onPasswordLengthChange) + + UppercaseAlphabetInput(viewState.includeUppercaseLetters, onUppercaseCheckedChange) + + LowercaseAlphabetInput(viewState.includeLowercaseLetters, onLowercaseCheckedChange) + + NumberInput(viewState.includeNumbers, onNumbersCheckedChange) + + SymbolInput(viewState.includeSymbols, onSymbolsCheckedChange) + } + } +} + +@Composable +private fun PasswordTextField( + password: String, + onCopyPasswordClick: () -> Unit +) { + OutlinedTextField( + value = password, + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + readOnly = true, + trailingIcon = { + IconButton( + onClick = onCopyPasswordClick + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy password" + ) + } + }, + label = { + Text(text = "Password") + } + ) +} + +@Composable +private fun PasswordLengthInput( + length: Int, + onPasswordLengthChange: (Float) -> Unit +) { + Text(text = "Password Length: $length") + + Slider( + value = length.toFloat(), + onValueChange = onPasswordLengthChange, + valueRange = 7f..50f, + steps = 43, + ) +} + +@Composable +private fun UppercaseAlphabetInput( + includeUppercaseLetters: Boolean, + onUppercaseCheckedChange: (Boolean) -> Unit +) { + CheckboxWithLabel( + label = "Uppercase Alphabets", + checked = includeUppercaseLetters, + onCheckedChange = onUppercaseCheckedChange, + ) +} + +@Composable +private fun LowercaseAlphabetInput( + includeLowercaseLetters: Boolean, + onLowercaseCheckedChange: (Boolean) -> Unit +) { + CheckboxWithLabel( + label = "Lowercase Alphabets", + checked = includeLowercaseLetters, + onCheckedChange = onLowercaseCheckedChange, + ) +} + +@Composable +private fun NumberInput( + includeNumbers: Boolean, + onNumbersCheckedChange: (Boolean) -> Unit +) { + CheckboxWithLabel( + label = "Numbers", + checked = includeNumbers, + onCheckedChange = onNumbersCheckedChange, + ) +} + +@Composable +private fun SymbolInput( + includeSymbols: Boolean, + onSymbolsCheckedChange: (Boolean) -> Unit +) { + CheckboxWithLabel( + label = "Symbols", + checked = includeSymbols, + onCheckedChange = onSymbolsCheckedChange, + ) +} + +@Preview( + name = "Night Mode", + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview( + name = "Day Mode", + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Composable +@Suppress("UnusedPrivateMember", "MagicNumber") +private fun GeneratePasswordContentPreview() { + + val viewState = GeneratePasswordViewState.Initial + + Mdc3Theme { + Surface { + GeneratePasswordContent( + viewState = viewState, + onGeneratePasswordClick = {}, + onCopyPasswordClick = {}, + onPasswordLengthChange = {}, + onUppercaseCheckedChange = {}, + onLowercaseCheckedChange = {}, + onNumbersCheckedChange = {}, + onSymbolsCheckedChange = {} + ) + } + } +} diff --git a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordScreen.kt b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordScreen.kt new file mode 100644 index 00000000..3ae91b72 --- /dev/null +++ b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/GeneratePasswordScreen.kt @@ -0,0 +1,43 @@ +package com.yogeshpaliyal.keypasscompose.ui.generate.ui + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import com.yogeshpaliyal.keypasscompose.R +import com.yogeshpaliyal.keypasscompose.ui.generate.GeneratePasswordViewModel +import com.yogeshpaliyal.keypasscompose.ui.generate.ui.utils.copyTextToClipboard + +@Composable +fun GeneratePasswordScreen(viewModel: GeneratePasswordViewModel) { + + val context = LocalContext.current + + // replace collectAsState() with collectAsStateWithLifecycle() when compose version and kotlin version are bumped up. + val viewState by viewModel.viewState.collectAsState() + + LaunchedEffect(key1 = Unit) { + viewModel.generatePassword() + } + + GeneratePasswordContent( + viewState = viewState, + onGeneratePasswordClick = viewModel::generatePassword, + onCopyPasswordClick = { onCopyPasswordClick(context, viewState.password) }, + onPasswordLengthChange = viewModel::onPasswordLengthSliderChange, + onUppercaseCheckedChange = viewModel::onUppercaseCheckedChange, + onLowercaseCheckedChange = viewModel::onLowercaseCheckedChange, + onNumbersCheckedChange = viewModel::onNumbersCheckedChange, + onSymbolsCheckedChange = viewModel::onSymbolsCheckedChange + ) +} + +private fun onCopyPasswordClick(context: Context, text: String) { + copyTextToClipboard(context = context, text = text, label = "random_password") + Toast + .makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT) + .show() +} diff --git a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/components/CheckboxWithLabel.kt b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/components/CheckboxWithLabel.kt new file mode 100644 index 00000000..08da97f2 --- /dev/null +++ b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/components/CheckboxWithLabel.kt @@ -0,0 +1,32 @@ +package com.yogeshpaliyal.keypasscompose.ui.generate.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun CheckboxWithLabel( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + + Text(text = label) + } +} diff --git a/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/utils/ClipboardUtils.kt b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/utils/ClipboardUtils.kt new file mode 100644 index 00000000..8cd4c4c3 --- /dev/null +++ b/keypasscompose/src/main/java/com/yogeshpaliyal/keypasscompose/ui/generate/ui/utils/ClipboardUtils.kt @@ -0,0 +1,17 @@ +package com.yogeshpaliyal.keypasscompose.ui.generate.ui.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.appcompat.app.AppCompatActivity + +fun copyTextToClipboard( + context: Context, + text: String, + label: String +) { + val clipboard = + context.getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) +} diff --git a/keypasscompose/src/main/res/layout/activity_generate_password.xml b/keypasscompose/src/main/res/layout/activity_generate_password.xml deleted file mode 100644 index 401dd93c..00000000 --- a/keypasscompose/src/main/res/layout/activity_generate_password.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file