From 39e1640a90114cb1d20dbcedee6588992f532d8e Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Tue, 3 Mar 2026 23:30:57 +0100 Subject: [PATCH] Migrate UI to Jetpack Compose --- app/build.gradle.kts | 25 +- .../cz/bugsy/passwordzebra/MainActivity.kt | 362 ++++++++++++++---- .../cz/bugsy/passwordzebra/PasswordZebra.kt | 10 +- .../ui/components/NumberPickerView.kt | 30 ++ .../cz/bugsy/passwordzebra/ui/theme/Theme.kt | 92 +++++ .../main/res/drawable/password_background.xml | 6 - .../main/res/layout-land/activity_main.xml | 167 -------- app/src/main/res/layout/activity_main.xml | 161 -------- app/src/main/res/values-night/themes.xml | 13 +- app/src/main/res/values/themes.xml | 9 +- app/src/main/res/values/themes_overlays.xml | 9 +- 11 files changed, 435 insertions(+), 449 deletions(-) create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/components/NumberPickerView.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/theme/Theme.kt delete mode 100644 app/src/main/res/drawable/password_background.xml delete mode 100644 app/src/main/res/layout-land/activity_main.xml delete mode 100644 app/src/main/res/layout/activity_main.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd045cb..d7252dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,15 +33,32 @@ android { kotlinOptions { jvmTarget = "1.8" } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.10" + } } dependencies { - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + val composeBom = platform("androidx.compose:compose-bom:2024.02.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") -} \ No newline at end of file + androidTestImplementation(composeBom) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt b/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt index 4ec83e7..89955f0 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt @@ -3,88 +3,296 @@ package cz.bugsy.passwordzebra import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.res.Configuration import android.os.Bundle -import android.widget.Button -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity - -private const val KEY_PASSWORD = "password" - -class MainActivity : AppCompatActivity() { - - private lateinit var generateButton: Button - private lateinit var passwordTextView: TextView - private lateinit var switchWithoutSpaces: com.google.android.material.switchmaterial.SwitchMaterial - private lateinit var switchSpecialChars: com.google.android.material.switchmaterial.SwitchMaterial - private lateinit var passwordLengthPicker: android.widget.NumberPicker +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cz.bugsy.passwordzebra.ui.components.NumberPickerView +import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - generateButton = findViewById(R.id.generateButton) - passwordTextView = findViewById(R.id.passwordTextView) - switchWithoutSpaces = findViewById(R.id.switchWithoutSpaces) - switchSpecialChars = findViewById(R.id.switchSpecialChars) - passwordLengthPicker = findViewById(R.id.passwordLengthPicker) - - // Set the range of selectable values for password length (1 to 10) - val defaultPasswordLength = 4 - passwordLengthPicker.minValue = 1 - passwordLengthPicker.maxValue = 10 - passwordLengthPicker.value = defaultPasswordLength - - generateButton.setOnClickListener { - generatePassword() - } - - // Restore password after rotation, otherwise generate a new one - if (savedInstanceState != null) { - passwordTextView.text = savedInstanceState.getString(KEY_PASSWORD, "") - } else { - generatePassword() - } - - // Set click listener to copy password to clipboard - passwordTextView.setOnClickListener { - copyToClipboard(passwordTextView.text.toString(), getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(KEY_PASSWORD, passwordTextView.text.toString()) - } - - private fun generatePassword() { - val passwordLength = passwordLengthPicker.value - val password = generateRandomWords(passwordLength) - passwordTextView.text = password - } - - private fun generateRandomWords(numWords: Int): String { - return buildString { - repeat(numWords) { - append(PasswordGenerator.generateRandomWord()) - append(" ") // Add space between words + enableEdgeToEdge() + setContent { + PasswordZebraTheme { + PasswordZebraApp() } - }.let { password -> - // Check if the switch for adding special characters is checked - if (switchSpecialChars.isChecked) { - PasswordGenerator.addSpecialCharacters(password) - } else { - password - } - }.trim() // Remove trailing space - } - - private fun copyToClipboard(text: String, clipboardManager: ClipboardManager) { - val clipData = if (switchWithoutSpaces.isChecked) { - val textNoSpaces = text.filter { !it.isWhitespace() } - ClipData.newPlainText("Password", textNoSpaces) - } else { - ClipData.newPlainText("Password", text) } - clipboardManager.setPrimaryClip(clipData) } } + +@Composable +private fun PasswordZebraApp() { + var password by rememberSaveable { mutableStateOf("") } + var wordCount by rememberSaveable { mutableIntStateOf(4) } + var withoutSpaces by rememberSaveable { mutableStateOf(false) } + var addSpecialChars by rememberSaveable { mutableStateOf(false) } + + val context = LocalContext.current + + LaunchedEffect(Unit) { + if (password.isEmpty()) { + password = generatePassword(wordCount, addSpecialChars) + } + } + + val onGenerate = { password = generatePassword(wordCount, addSpecialChars) } + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + if (isLandscape) { + // Landscape: controls left (vertically centered), password + generate button right + Row( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Left: controls only, vertically centered + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ControlsPanel( + wordCount = wordCount, + withoutSpaces = withoutSpaces, + addSpecialChars = addSpecialChars, + onWordCountChange = { wordCount = it }, + onWithoutSpacesChange = { withoutSpaces = it }, + onAddSpecialCharsChange = { addSpecialChars = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + // Right: password box fills remaining height, generate button pinned to bottom + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PasswordBox( + password = password, + onClick = { copyToClipboard(context, password, withoutSpaces) }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + Spacer(modifier = Modifier.height(12.dp)) + GenerateButton(onClick = onGenerate) + } + } + } else { + // Portrait: password box top, controls middle, generate button pinned to bottom + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + PasswordBox( + password = password, + onClick = { copyToClipboard(context, password, withoutSpaces) }, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + ) + Spacer(modifier = Modifier.weight(2f)) + ControlsPanel( + wordCount = wordCount, + withoutSpaces = withoutSpaces, + addSpecialChars = addSpecialChars, + onWordCountChange = { wordCount = it }, + onWithoutSpacesChange = { withoutSpaces = it }, + onAddSpecialCharsChange = { addSpecialChars = it }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.weight(3f)) + GenerateButton(onClick = onGenerate) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun PasswordBox(password: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ) + .clickable { onClick() } + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = password, + fontSize = 24.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun ControlsPanel( + wordCount: Int, + withoutSpaces: Boolean, + addSpecialChars: Boolean, + onWordCountChange: (Int) -> Unit, + onWithoutSpacesChange: (Boolean) -> Unit, + onAddSpecialCharsChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(R.drawable.pw_straighten), + contentDescription = stringResource(R.string.image_length_desc), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.textview_password_length), + modifier = Modifier.weight(1f), + ) + NumberPickerView( + value = wordCount, + minValue = 1, + maxValue = 10, + onValueChange = onWordCountChange, + modifier = Modifier.widthIn(min = 80.dp), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(R.drawable.pw_letter_spacing), + contentDescription = stringResource(R.string.image_wospaces), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.switch_without_spaces), + modifier = Modifier.weight(1f), + ) + Switch( + checked = withoutSpaces, + onCheckedChange = onWithoutSpacesChange, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(R.drawable.pw_special_character), + contentDescription = stringResource(R.string.image_specialchars), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.switch_special_characters), + modifier = Modifier.weight(1f), + ) + Switch( + checked = addSpecialChars, + onCheckedChange = onAddSpecialCharsChange, + ) + } + } +} + +@Composable +private fun GenerateButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + FilledIconButton( + onClick = onClick, + modifier = modifier.size(60.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + painter = painterResource(R.drawable.pw_generate), + contentDescription = stringResource(R.string.button_generate_description), + ) + } +} + +private fun generatePassword(wordCount: Int, addSpecialChars: Boolean): String { + return buildString { + repeat(wordCount) { + append(PasswordGenerator.generateRandomWord()) + append(" ") + } + }.let { password -> + if (addSpecialChars) { + PasswordGenerator.addSpecialCharacters(password) + } else { + password + } + }.trim() +} + +private fun copyToClipboard(context: Context, text: String, withoutSpaces: Boolean) { + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipText = if (withoutSpaces) text.filter { !it.isWhitespace() } else text + val clipData = ClipData.newPlainText("Password", clipText) + clipboardManager.setPrimaryClip(clipData) +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/PasswordZebra.kt b/app/src/main/java/cz/bugsy/passwordzebra/PasswordZebra.kt index a5d6b7b..767bf90 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/PasswordZebra.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/PasswordZebra.kt @@ -2,16 +2,10 @@ package cz.bugsy.passwordzebra import android.app.Application import com.google.android.material.color.DynamicColors -import com.google.android.material.color.DynamicColorsOptions class PasswordZebra : Application() { override fun onCreate() { - // Apply dynamic color - DynamicColors.applyToActivitiesIfAvailable( - this, - DynamicColorsOptions.Builder() - .setThemeOverlay(R.style.AppTheme_Overlay) - .build() - ) + super.onCreate() + DynamicColors.applyToActivitiesIfAvailable(this) } } diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/components/NumberPickerView.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/components/NumberPickerView.kt new file mode 100644 index 0000000..ced2681 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/components/NumberPickerView.kt @@ -0,0 +1,30 @@ +package cz.bugsy.passwordzebra.ui.components + +import android.widget.NumberPicker +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun NumberPickerView( + value: Int, + minValue: Int, + maxValue: Int, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + AndroidView( + factory = { ctx -> + NumberPicker(ctx).apply { + this.minValue = minValue + this.maxValue = maxValue + this.value = value + setOnValueChangedListener { _, _, new -> onValueChange(new) } + } + }, + update = { picker -> + if (picker.value != value) picker.value = value + }, + modifier = modifier, + ) +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/theme/Theme.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/theme/Theme.kt new file mode 100644 index 0000000..28cb7ab --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/theme/Theme.kt @@ -0,0 +1,92 @@ +package cz.bugsy.passwordzebra.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF286C2A), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFABF5A3), + onPrimaryContainer = Color(0xFF002203), + secondary = Color(0xFF00639A), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFCEE5FF), + onSecondaryContainer = Color(0xFF001D32), + tertiary = Color(0xFF38656A), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFBCEBF0), + onTertiaryContainer = Color(0xFF002023), + error = Color(0xFFBA1A1A), + errorContainer = Color(0xFFFFDAD6), + onError = Color(0xFFFFFFFF), + onErrorContainer = Color(0xFF410002), + background = Color(0xFFFCFDF6), + onBackground = Color(0xFF1A1C19), + surface = Color(0xFFFCFDF6), + onSurface = Color(0xFF1A1C19), + surfaceVariant = Color(0xFFDEE5D8), + onSurfaceVariant = Color(0xFF424940), + outline = Color(0xFF72796F), + inverseOnSurface = Color(0xFFF1F1EB), + inverseSurface = Color(0xFF2F312D), + inversePrimary = Color(0xFF90D889), + surfaceTint = Color(0xFF286C2A), + outlineVariant = Color(0xFFC2C9BD), + scrim = Color(0xFF000000), +) + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF90D889), + onPrimary = Color(0xFF003909), + primaryContainer = Color(0xFF085314), + onPrimaryContainer = Color(0xFFABF5A3), + secondary = Color(0xFF96CCFF), + onSecondary = Color(0xFF003353), + secondaryContainer = Color(0xFF004A75), + onSecondaryContainer = Color(0xFFCEE5FF), + tertiary = Color(0xFFA0CFD4), + onTertiary = Color(0xFF00363B), + tertiaryContainer = Color(0xFF1E4D52), + onTertiaryContainer = Color(0xFFBCEBF0), + error = Color(0xFFFFB4AB), + errorContainer = Color(0xFF93000A), + onError = Color(0xFF690005), + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF1A1C19), + onBackground = Color(0xFFE2E3DD), + surface = Color(0xFF1A1C19), + onSurface = Color(0xFFE2E3DD), + surfaceVariant = Color(0xFF424940), + onSurfaceVariant = Color(0xFFC2C9BD), + outline = Color(0xFF8C9388), + inverseOnSurface = Color(0xFF1A1C19), + inverseSurface = Color(0xFFE2E3DD), + inversePrimary = Color(0xFF286C2A), + surfaceTint = Color(0xFF90D889), + outlineVariant = Color(0xFF424940), + scrim = Color(0xFF000000), +) + +@Composable +fun PasswordZebraTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val ctx = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + MaterialTheme(colorScheme = colorScheme, content = content) +} diff --git a/app/src/main/res/drawable/password_background.xml b/app/src/main/res/drawable/password_background.xml deleted file mode 100644 index 3369cc4..0000000 --- a/app/src/main/res/drawable/password_background.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml deleted file mode 100644 index 3556290..0000000 --- a/app/src/main/res/layout-land/activity_main.xml +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - - - - - - -