diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 123242c148..d400b87806 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -134,6 +134,7 @@ dependencies { implementation(libs.androidx.compose.material3.material3) implementation(libs.androidx.compose.material.material.icons.extended) implementation(libs.androidx.compose.ui.ui.tooling.preview.android) + debugImplementation(libs.androidx.compose.ui.ui.tooling) debugImplementation(libs.androidx.compose.ui.ui.test.manifest) androidTestImplementation(composeBom) @@ -154,6 +155,8 @@ dependencies { testImplementation(libs.androidx.test.core) testImplementation(libs.junit.junit) testImplementation(libs.org.robolectric.robolectric) + testImplementation(composeBom) + testImplementation(libs.androidx.compose.ui.ui.test.junit4) androidTestImplementation(libs.bundles.androidx.test) androidTestImplementation(libs.junit.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 596b2f9ba6..320ae34a5c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,12 @@ android:resource="@xml/list_widget_info" /> + + + private lateinit var fileOpenLauncher: ActivityResultLauncher private val mTasks = TaskHandler() + private var dialogState by mutableStateOf(ImportExportDialogState.None) + companion object { private const val TAG = "Catima" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ImportExportActivityBinding.inflate(layoutInflater) - setTitle(R.string.importExport) - setContentView(binding.root) - Utils.applyWindowInsets(binding.root) - val toolbar: Toolbar = binding.toolbar - setSupportActionBar(toolbar) - enableToolbarBackButton() + + fixedEdgeToEdge() val fileIntent = intent if (fileIntent?.type != null) { - chooseImportType(fileIntent.data) + fileIntent.data?.let { uri -> + pendingImportUri = uri + dialogState = ImportExportDialogState.ImportTypeSelection + } } // would use ActivityResultContracts.CreateDocument() but mime type cannot be set @@ -103,155 +96,121 @@ class ImportExportActivity : CatimaAppCompatActivity() { openFileForImport(result, null) } - // Check that there is a file manager available + val importOptions = buildImportOptions() + + setContent { + CatimaTheme { + ImportExportScreen( + onBackPressedDispatcher = onBackPressedDispatcher, + importOptions = importOptions, + dialogState = dialogState, + onDialogStateChange = { newState -> dialogState = newState }, + onExportWithPassword = { password -> startExportFlow(password) }, + onImportSelected = { option -> handleImportSelection(option) }, + onImportWithPassword = { _, password -> + pendingImportUri?.let { uri -> + openFileForImport(uri, password.toCharArray()) + } + }, + onShareExport = { shareExportedFile() } + ) + } + } + + // FIXME: The importer/exporter is currently quite broken + // To prevent the screen from turning off during import/export and some devices killing Catima as it's no longer foregrounded, force the screen to stay on here + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + private fun buildImportOptions(): List { + val importTypesArray = resources.getStringArray(R.array.import_types_array) + return listOf( + ImportOption( + title = importTypesArray.getOrElse(0) { getString(R.string.importCatima) }, + message = getString(R.string.importCatimaMessage), + dataFormat = DataFormat.Catima + ), + ImportOption( + title = importTypesArray.getOrElse(1) { "Fidme" }, + message = getString(R.string.importFidmeMessage), + dataFormat = DataFormat.Fidme, + isBeta = true + ), + ImportOption( + title = importTypesArray.getOrElse(2) { getString(R.string.importLoyaltyCardKeychain) }, + message = getString(R.string.importLoyaltyCardKeychainMessage), + dataFormat = DataFormat.Catima + ), + ImportOption( + title = importTypesArray.getOrElse(3) { getString(R.string.importVoucherVault) }, + message = getString(R.string.importVoucherVaultMessage), + dataFormat = DataFormat.VoucherVault + ) + ) + } + + private fun startExportFlow(password: String) { + exportPassword = password val intentCreateDocumentAction = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/zip" putExtra(Intent.EXTRA_TITLE, "catima.zip") } + try { + fileCreateLauncher.launch(intentCreateDocumentAction) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + applicationContext, + R.string.failedOpeningFileManager, + Toast.LENGTH_LONG + ).show() + Log.e(TAG, "No activity found to handle intent", e) + } + } - val exportButton: Button = binding.exportButton - exportButton.setOnClickListener { - val builder = MaterialAlertDialogBuilder(this@ImportExportActivity) - builder.setTitle(R.string.exportPassword) - - val container = FrameLayout(this@ImportExportActivity) - - val textInputLayout = TextInputLayout(this@ImportExportActivity).apply { - endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(50, 10, 50, 0) - } - } - - val input = EditText(this@ImportExportActivity).apply { - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - setHint(R.string.exportPasswordHint) - } + private fun handleImportSelection(option: ImportOption) { + currentImportDataFormat = option.dataFormat - textInputLayout.addView(input) - container.addView(textInputLayout) - builder.setView(container) - builder.setPositiveButton(R.string.ok) { _, _ -> - exportPassword = input.text.toString() - try { - fileCreateLauncher.launch(intentCreateDocumentAction) - } catch (e: ActivityNotFoundException) { - Toast.makeText( - applicationContext, - R.string.failedOpeningFileManager, - Toast.LENGTH_LONG - ).show() - Log.e(TAG, "No activity found to handle intent", e) - } - } - builder.setNegativeButton(R.string.cancel) { dialogInterface, _ -> dialogInterface.cancel() } - builder.show() + // If we have a pending URI from intent, use it directly + pendingImportUri?.let { uri -> + openFileForImport(uri, null) + return } - // Check that there is a file manager available - val importFilesystem: Button = binding.importOptionFilesystemButton - importFilesystem.setOnClickListener { chooseImportType(null) } - - // FIXME: The importer/exporter is currently quite broken - // To prevent the screen from turning off during import/export and some devices killing Catima as it's no longer foregrounded, force the screen to stay on here - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // Otherwise open file picker + try { + fileOpenLauncher.launch("*/*") + } catch (e: ActivityNotFoundException) { + Toast.makeText( + applicationContext, + R.string.failedOpeningFileManager, + Toast.LENGTH_LONG + ).show() + Log.e(TAG, "No activity found to handle intent", e) + } } private fun openFileForImport(uri: Uri, password: CharArray?) { + pendingImportUri = uri // Running this in a thread prevents Android from throwing a NetworkOnMainThreadException for large files // FIXME: This is still suboptimal, because showing that the import started is delayed until the network request finishes Thread { try { val reader = contentResolver.openInputStream(uri) Log.d(TAG, "Starting file import with: $uri") - startImport(reader, uri, importDataFormat, password, true) + startImport(reader, uri, currentImportDataFormat, password, true) } catch (e: IOException) { Log.e(TAG, "Failed to import file: $uri", e) onImportComplete( ImportExportResult( ImportExportResultType.GenericFailure, e.toString() - ), uri, importDataFormat + ), currentImportDataFormat ) } }.start() } - private fun chooseImportType(fileData: Uri?) { - val betaImportOptions = mutableListOf() - betaImportOptions.add("Fidme") - val importOptions = mutableListOf() - - for (importOption in resources.getStringArray(R.array.import_types_array)) { - var option = importOption - if (betaImportOptions.contains(importOption)) { - option = "$importOption (BETA)" - } - importOptions.add(option) - } - - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle(R.string.chooseImportType) - .setItems(importOptions.toTypedArray()) { _, which -> - when (which) { - // Catima - 0 -> { - importAlertTitle = getString(R.string.importCatima) - importAlertMessage = getString(R.string.importCatimaMessage) - importDataFormat = DataFormat.Catima - } - // Fidme - 1 -> { - importAlertTitle = getString(R.string.importFidme) - importAlertMessage = getString(R.string.importFidmeMessage) - importDataFormat = DataFormat.Fidme - } - // Loyalty Card Keychain - 2 -> { - importAlertTitle = getString(R.string.importLoyaltyCardKeychain) - importAlertMessage = getString(R.string.importLoyaltyCardKeychainMessage) - importDataFormat = DataFormat.Catima - } - // Voucher Vault - 3 -> { - importAlertTitle = getString(R.string.importVoucherVault) - importAlertMessage = getString(R.string.importVoucherVaultMessage) - importDataFormat = DataFormat.VoucherVault - } - - else -> throw IllegalArgumentException("Unknown DataFormat") - } - - if (fileData != null) { - openFileForImport(fileData, null) - return@setItems - } - - MaterialAlertDialogBuilder(this) - .setTitle(importAlertTitle) - .setMessage(importAlertMessage) - .setPositiveButton(R.string.ok) { _, _ -> - try { - fileOpenLauncher.launch("*/*") - } catch (e: ActivityNotFoundException) { - Toast.makeText( - applicationContext, - R.string.failedOpeningFileManager, - Toast.LENGTH_LONG - ).show() - Log.e(TAG, "No activity found to handle intent", e) - } - } - .setNegativeButton(R.string.cancel, null) - .show() - } - builder.show() - } - private fun startImport( target: InputStream?, targetUri: Uri, @@ -260,8 +219,8 @@ class ImportExportActivity : CatimaAppCompatActivity() { closeWhenDone: Boolean ) { mTasks.flushTaskList(TaskHandler.TYPE.IMPORT, true, false, false) - val listener = ImportExportTask.TaskCompleteListener { result, dataFormat -> - onImportComplete(result, targetUri, dataFormat) + val listener = ImportExportTask.TaskCompleteListener { result, format -> + onImportComplete(result, format) if (closeWhenDone) { try { target?.close() @@ -285,7 +244,7 @@ class ImportExportActivity : CatimaAppCompatActivity() { closeWhenDone: Boolean ) { mTasks.flushTaskList(TaskHandler.TYPE.EXPORT, true, false, false) - val listener = ImportExportTask.TaskCompleteListener { result, dataFormat -> + val listener = ImportExportTask.TaskCompleteListener { result, _ -> onExportComplete(result, targetUri) if (closeWhenDone) { try { @@ -309,108 +268,38 @@ class ImportExportActivity : CatimaAppCompatActivity() { super.onDestroy() } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - - if (id == android.R.id.home) { - finish() - return true - } - - return super.onOptionsItemSelected(item) - } - - private fun retryWithPassword(dataFormat: DataFormat, uri: Uri) { - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle(R.string.passwordRequired) - - val container = FrameLayout(this@ImportExportActivity) - - val textInputLayout = TextInputLayout(this@ImportExportActivity).apply { - endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(50, 10, 50, 0) - } - } - - val input = EditText(this@ImportExportActivity).apply { - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - setHint(R.string.exportPasswordHint) - } - - textInputLayout.addView(input) - container.addView(textInputLayout) - builder.setView(container) - - builder.setPositiveButton(R.string.ok) { _, _ -> - openFileForImport(uri, input.text.toString().toCharArray()) - } - builder.setNegativeButton(R.string.cancel) { dialogInterface, _ -> dialogInterface.cancel() } - - builder.show() - } - - private fun buildResultDialogMessage(result: ImportExportResult, isImport: Boolean): String { - val messageId = if (result.resultType() == ImportExportResultType.Success) { - if (isImport) R.string.importSuccessful else R.string.exportSuccessful - } else { - if (isImport) R.string.importFailed else R.string.exportFailed - } - - val messageBuilder = StringBuilder(resources.getString(messageId)) - if (result.developerDetails() != null) { - messageBuilder.append("\n\n") - messageBuilder.append(resources.getString(R.string.include_if_asking_support)) - messageBuilder.append("\n\n") - messageBuilder.append(result.developerDetails()) - } - - return messageBuilder.toString() - } - - private fun onImportComplete(result: ImportExportResult, path: Uri, dataFormat: DataFormat?) { + private fun onImportComplete(result: ImportExportResult, dataFormat: DataFormat?) { val resultType = result.resultType() if (resultType == ImportExportResultType.BadPassword) { - retryWithPassword(dataFormat!!, path) + runOnUiThread { + dialogState = ImportExportDialogState.ImportPassword(dataFormat!!) + } return } - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle(if (resultType == ImportExportResultType.Success) R.string.importSuccessfulTitle else R.string.importFailedTitle) - builder.setMessage(buildResultDialogMessage(result, true)) - builder.setNeutralButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - - builder.create().show() + runOnUiThread { + dialogState = ImportExportDialogState.ImportResult(result, dataFormat) + } } private fun onExportComplete(result: ImportExportResult, path: Uri) { - val resultType = result.resultType() - - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle(if (resultType == ImportExportResultType.Success) R.string.exportSuccessfulTitle else R.string.exportFailedTitle) - builder.setMessage(buildResultDialogMessage(result, false)) - builder.setNeutralButton(R.string.ok) { dialog, _ -> dialog.dismiss() } - - if (resultType == ImportExportResultType.Success) { - val sendLabel = this@ImportExportActivity.resources.getText(R.string.sendLabel) - - builder.setPositiveButton(sendLabel) { dialog, _ -> - val sendIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, path) - type = "text/csv" - // set flag to give temporary permission to external app to use the FileProvider - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - } + lastExportUri = path + val isSuccess = result.resultType() == ImportExportResultType.Success + runOnUiThread { + dialogState = ImportExportDialogState.ExportResult(result, canShare = isSuccess) + } + } - this@ImportExportActivity.startActivity(Intent.createChooser(sendIntent, sendLabel)) - dialog.dismiss() + private fun shareExportedFile() { + lastExportUri?.let { uri -> + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + type = "text/csv" + // set flag to give temporary permission to external app to use the FileProvider + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION } + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.sendLabel))) } - - builder.create().show() } } \ No newline at end of file diff --git a/app/src/main/java/protect/card_locker/ImportExportScreen.kt b/app/src/main/java/protect/card_locker/ImportExportScreen.kt new file mode 100644 index 0000000000..d85952dffc --- /dev/null +++ b/app/src/main/java/protect/card_locker/ImportExportScreen.kt @@ -0,0 +1,504 @@ +package protect.card_locker + +import androidx.activity.OnBackPressedDispatcher +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import protect.card_locker.compose.CatimaTopAppBar +import protect.card_locker.importexport.DataFormat +import protect.card_locker.importexport.ImportExportResult +import protect.card_locker.importexport.ImportExportResultType + +data class ImportOption( + val title: String, + val message: String, + val dataFormat: DataFormat, + val isBeta: Boolean = false +) + +sealed class ImportExportDialogState { + data object None : ImportExportDialogState() + data object ExportPassword : ImportExportDialogState() + data object ImportTypeSelection : ImportExportDialogState() + data class ImportConfirmation(val option: ImportOption) : ImportExportDialogState() + data class ImportPassword(val dataFormat: DataFormat) : ImportExportDialogState() + data class ImportResult(val result: ImportExportResult, val dataFormat: DataFormat?) : + ImportExportDialogState() + + data class ExportResult(val result: ImportExportResult, val canShare: Boolean) : + ImportExportDialogState() +} + +@Composable +fun ImportExportScreen( + onBackPressedDispatcher: OnBackPressedDispatcher?, + importOptions: List, + dialogState: ImportExportDialogState, + onDialogStateChange: (ImportExportDialogState) -> Unit, + onExportWithPassword: (String) -> Unit, + onImportSelected: (ImportOption) -> Unit, + onImportWithPassword: (DataFormat, String) -> Unit, + onShareExport: () -> Unit, +) { + Scaffold( + topBar = { + CatimaTopAppBar( + title = stringResource(R.string.importExport), + onBackPressedDispatcher = onBackPressedDispatcher + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.importExportHelp), + fontSize = 16.sp, + textAlign = TextAlign.Center + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + text = stringResource(R.string.exportName), + fontSize = 20.sp, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.exportOptionExplanation), + fontSize = 16.sp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + Button( + onClick = { onDialogStateChange(ImportExportDialogState.ExportPassword) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.exportName)) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + text = stringResource(R.string.importOptionFilesystemTitle), + fontSize = 20.sp, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.importOptionFilesystemExplanation), + fontSize = 16.sp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + Button( + onClick = { onDialogStateChange(ImportExportDialogState.ImportTypeSelection) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.importOptionFilesystemButton)) + } + } + + // Dialogs + when (dialogState) { + is ImportExportDialogState.ExportPassword -> { + ExportPasswordDialog( + onDismiss = { onDialogStateChange(ImportExportDialogState.None) }, + onConfirm = { password -> + onDialogStateChange(ImportExportDialogState.None) + onExportWithPassword(password) + } + ) + } + + is ImportExportDialogState.ImportTypeSelection -> { + ImportTypeSelectionDialog( + importOptions = importOptions, + onDismiss = { onDialogStateChange(ImportExportDialogState.None) }, + onSelect = { option -> + onDialogStateChange(ImportExportDialogState.ImportConfirmation(option)) + } + ) + } + + is ImportExportDialogState.ImportConfirmation -> { + ImportConfirmationDialog( + option = dialogState.option, + onDismiss = { onDialogStateChange(ImportExportDialogState.None) }, + onConfirm = { + onDialogStateChange(ImportExportDialogState.None) + onImportSelected(dialogState.option) + } + ) + } + + is ImportExportDialogState.ImportPassword -> { + ImportPasswordDialog( + onDismiss = { onDialogStateChange(ImportExportDialogState.None) }, + onConfirm = { password -> + onDialogStateChange(ImportExportDialogState.None) + onImportWithPassword(dialogState.dataFormat, password) + } + ) + } + + is ImportExportDialogState.ImportResult -> { + ImportResultDialog( + result = dialogState.result, + onDismiss = { onDialogStateChange(ImportExportDialogState.None) } + ) + } + + is ImportExportDialogState.ExportResult -> { + ExportResultDialog( + result = dialogState.result, + canShare = dialogState.canShare, + onDismiss = { onDialogStateChange(ImportExportDialogState.None) }, + onShare = { + onDialogStateChange(ImportExportDialogState.None) + onShareExport() + } + ) + } + + ImportExportDialogState.None -> { /* No dialog */ + } + } + } +} + +@Composable +private fun ExportPasswordDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.exportPassword)) }, + text = { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.exportPasswordHint)) }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(password) }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun ImportTypeSelectionDialog( + importOptions: List, + onDismiss: () -> Unit, + onSelect: (ImportOption) -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.chooseImportType)) }, + text = { + Column { + importOptions.forEach { option -> + val displayTitle = if (option.isBeta) "${option.title} (BETA)" else option.title + Text( + text = displayTitle, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option) } + .padding(vertical = 12.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun ImportConfirmationDialog( + option: ImportOption, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(option.title) }, + text = { Text(option.message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun ImportPasswordDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.passwordRequired)) }, + text = { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.exportPasswordHint)) }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(password) }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun ImportResultDialog( + result: ImportExportResult, + onDismiss: () -> Unit +) { + val isSuccess = result.resultType() == ImportExportResultType.Success + val titleRes = if (isSuccess) R.string.importSuccessfulTitle else R.string.importFailedTitle + val messageRes = if (isSuccess) R.string.importSuccessful else R.string.importFailed + + val message = buildString { + append(stringResource(messageRes)) + if (result.developerDetails() != null) { + append("\n\n") + append(stringResource(R.string.include_if_asking_support)) + append("\n\n") + append(result.developerDetails()) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(titleRes)) }, + text = { + Text( + text = message, + modifier = Modifier.verticalScroll(rememberScrollState()) + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ok)) + } + } + ) +} + +@Composable +private fun ExportResultDialog( + result: ImportExportResult, + canShare: Boolean, + onDismiss: () -> Unit, + onShare: () -> Unit +) { + val isSuccess = result.resultType() == ImportExportResultType.Success + val titleRes = if (isSuccess) R.string.exportSuccessfulTitle else R.string.exportFailedTitle + val messageRes = if (isSuccess) R.string.exportSuccessful else R.string.exportFailed + + val message = buildString { + append(stringResource(messageRes)) + if (result.developerDetails() != null) { + append("\n\n") + append(stringResource(R.string.include_if_asking_support)) + append("\n\n") + append(result.developerDetails()) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(titleRes)) }, + text = { + Text( + text = message, + modifier = Modifier.verticalScroll(rememberScrollState()) + ) + }, + confirmButton = { + if (isSuccess && canShare) { + TextButton(onClick = onShare) { + Text(stringResource(R.string.sendLabel)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ok)) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +private fun ImportExportScreenPreview() { + val sampleImportOptions = listOf( + ImportOption( + title = "Catima", + message = "Import from Catima backup", + dataFormat = DataFormat.Catima + ), + ImportOption( + title = "Fidme", + message = "Import from Fidme", + dataFormat = DataFormat.Fidme, + isBeta = true + ), + ImportOption( + title = "Loyalty Card Keychain", + message = "Import from Loyalty Card Keychain", + dataFormat = DataFormat.Catima + ), + ImportOption( + title = "Voucher Vault", + message = "Import from Voucher Vault", + dataFormat = DataFormat.VoucherVault + ) + ) + + ImportExportScreen( + onBackPressedDispatcher = null, + importOptions = sampleImportOptions, + dialogState = ImportExportDialogState.None, + onDialogStateChange = {}, + onExportWithPassword = {}, + onImportSelected = {}, + onImportWithPassword = { _, _ -> }, + onShareExport = {} + ) +} + +@Preview(showBackground = true) +@Composable +private fun ImportTypeSelectionDialogPreview() { + val sampleImportOptions = listOf( + ImportOption( + title = "Catima", + message = "Import from Catima backup", + dataFormat = DataFormat.Catima + ), + ImportOption( + title = "Fidme", + message = "Import from Fidme", + dataFormat = DataFormat.Fidme, + isBeta = true + ) + ) + + ImportTypeSelectionDialog( + importOptions = sampleImportOptions, + onDismiss = {}, + onSelect = {} + ) +} + +@Preview(showBackground = true) +@Composable +private fun ExportPasswordDialogPreview() { + ExportPasswordDialog( + onDismiss = {}, + onConfirm = {} + ) +} \ No newline at end of file diff --git a/app/src/main/res/layout/import_export_activity.xml b/app/src/main/res/layout/import_export_activity.xml deleted file mode 100644 index e24a496607..0000000000 --- a/app/src/main/res/layout/import_export_activity.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -