Coverage Summary for Class: BackupScreenKt (com.georgeci.moneysurfer.feature.settings.backup)

Class Method, % Branch, % Line, % Instruction, %
BackupScreenKt 0% (0/39) 0% (0/122) 0% (0/173) 0% (0/2782)
BackupScreenKt$BackupScreen$1$1 0% (0/1) 0% (0/2) 0% (0/3) 0% (0/33)
Total 0% (0/40) 0% (0/124) 0% (0/176) 0% (0/2815)


 package com.georgeci.moneysurfer.feature.settings.backup
 
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.SnackbarDuration
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.text.input.PasswordVisualTransformation
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.domain.backup.AppRestarter
 import com.georgeci.moneysurfer.feature.settings.components.SettingsSubScreenScaffold
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsChevron
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsGroup
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsRow
 import com.georgeci.moneysurfer.uikit.components.settings.SurferStatusHeroCard
 import com.georgeci.moneysurfer.uikit.components.settings.SurferStatusHeroTone
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import com.georgeci.moneysurfer.utils.HandleSideEffect
 import moneysurfer.feature.settings.generated.resources.Res
 import moneysurfer.feature.settings.generated.resources.settings_backup_cancel
 import moneysurfer.feature.settings.generated.resources.settings_backup_download_supporting
 import moneysurfer.feature.settings.generated.resources.settings_backup_download_title
 import moneysurfer.feature.settings.generated.resources.settings_backup_export_confirm
 import moneysurfer.feature.settings.generated.resources.settings_backup_export_passphrase_confirm_label
 import moneysurfer.feature.settings.generated.resources.settings_backup_export_passphrase_label
 import moneysurfer.feature.settings.generated.resources.settings_backup_export_passphrase_mismatch
 import moneysurfer.feature.settings.generated.resources.settings_backup_export_success
 import moneysurfer.feature.settings.generated.resources.settings_backup_export_warning
 import moneysurfer.feature.settings.generated.resources.settings_backup_hero_supporting
 import moneysurfer.feature.settings.generated.resources.settings_backup_hero_title
 import moneysurfer.feature.settings.generated.resources.settings_backup_import_passphrase_body
 import moneysurfer.feature.settings.generated.resources.settings_backup_import_passphrase_confirm
 import moneysurfer.feature.settings.generated.resources.settings_backup_import_passphrase_label
 import moneysurfer.feature.settings.generated.resources.settings_backup_import_passphrase_title
 import moneysurfer.feature.settings.generated.resources.settings_backup_local_hero_supporting
 import moneysurfer.feature.settings.generated.resources.settings_backup_local_hero_title
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_corrupted
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_format_mismatch
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_generic
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_invalid_archive
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_missing_file
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_passphrase_required
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_schema_mismatch
 import moneysurfer.feature.settings.generated.resources.settings_backup_notice_wrong_passphrase
 import moneysurfer.feature.settings.generated.resources.settings_backup_relaunch_body
 import moneysurfer.feature.settings.generated.resources.settings_backup_relaunch_confirm
 import moneysurfer.feature.settings.generated.resources.settings_backup_relaunch_title
 import moneysurfer.feature.settings.generated.resources.settings_backup_restore_confirm_body
 import moneysurfer.feature.settings.generated.resources.settings_backup_restore_confirm_confirm
 import moneysurfer.feature.settings.generated.resources.settings_backup_restore_confirm_title
 import moneysurfer.feature.settings.generated.resources.settings_backup_restore_supporting
 import moneysurfer.feature.settings.generated.resources.settings_backup_restore_title
 import moneysurfer.feature.settings.generated.resources.settings_backup_section_manual
 import moneysurfer.feature.settings.generated.resources.settings_backup_section_restore
 import moneysurfer.feature.settings.generated.resources.settings_backup_title
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.koinInject
 import org.koin.compose.viewmodel.koinViewModel
 
 @Composable
 fun BackupScreen(
     onNavigateBack: () -> Unit,
     viewModel: BackupViewModel = koinViewModel(),
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
     val appRestarter: AppRestarter = koinInject()
     val snackbarHostState = remember { SnackbarHostState() }
     var pendingNotice by remember { mutableStateOf<BackupNotice?>(null) }
     var showRelaunchNotice by remember { mutableStateOf(false) }
 
     val launcher = rememberBackupPickerLauncher(
         format = BackupPickerFormat.Zip,
         onSavePicked = { sink -> viewModel.onEvent(BackupEvent.OnSaveSinkChosen(sink)) },
         onOpenPicked = { source -> viewModel.onEvent(BackupEvent.OnOpenSourceChosen(source)) },
     )
 
     // Resolve through Compose's standard format-args path so positional
     // placeholders survive translator reordering. We snapshot at composition;
     // the LaunchedEffect below feeds the snackbar from the resolved value.
     val pendingNoticeText: String? = pendingNotice?.let { noticeText(it) }
     LaunchedEffect(pendingNoticeText) {
         val text = pendingNoticeText ?: return@LaunchedEffect
         snackbarHostState.showSnackbar(message = text, duration = SnackbarDuration.Short)
         pendingNotice = null
     }
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             BackupEffect.NavigateBack -> onNavigateBack()
             is BackupEffect.RequestSaveFile -> launcher.launchSave(effect.suggestedName)
             BackupEffect.RequestOpenFile -> launcher.launchOpen()
             // Hosts that cannot relaunch themselves (iOS exits the process) get an
             // explicit "we're about to close, please reopen" step first — otherwise a
             // successful restore is indistinguishable from a crash.
             BackupEffect.RestartApp ->
                 if (appRestarter.requiresManualRelaunch) {
                     showRelaunchNotice = true
                 } else {
                     appRestarter.restart()
                 }
             // A notice is how this screen learns an operation ended. Only the
             // export leaves the picker holding a staged file, and the progress
             // scrim keeps the two flows from overlapping, so the notice that
             // lands here is the one belonging to that export.
             is BackupEffect.Notify -> {
                 pendingNotice = effect.notice
                 launcher.onSaveCompleted(effect.notice == BackupNotice.ExportSuccess)
             }
         }
     }
 
     if (showRelaunchNotice) {
         RelaunchNoticeDialog(onConfirm = { appRestarter.restart() })
     }
 
     BackupContent(
         state = state,
         snackbarHostState = snackbarHostState,
         onEvent = viewModel::onEvent,
     )
 }
 
 @Composable
 private fun noticeText(notice: BackupNotice): String? = when (notice) {
     BackupNotice.ExportSuccess -> stringResource(Res.string.settings_backup_export_success)
     BackupNotice.Cancelled -> null
     BackupNotice.InvalidArchive -> stringResource(Res.string.settings_backup_notice_invalid_archive)
     BackupNotice.Corrupted -> stringResource(Res.string.settings_backup_notice_corrupted)
     BackupNotice.PassphraseRequired ->
         stringResource(Res.string.settings_backup_notice_passphrase_required)
     BackupNotice.WrongPassphrase ->
         stringResource(Res.string.settings_backup_notice_wrong_passphrase)
     BackupNotice.Generic -> stringResource(Res.string.settings_backup_notice_generic)
     is BackupNotice.MissingFile ->
         stringResource(Res.string.settings_backup_notice_missing_file, notice.name)
     is BackupNotice.FormatMismatch ->
         stringResource(Res.string.settings_backup_notice_format_mismatch, notice.actual, notice.expected)
     is BackupNotice.SchemaMismatch ->
         stringResource(Res.string.settings_backup_notice_schema_mismatch, notice.actual, notice.expected)
 }
 
 @Composable
 private fun BackupContent(
     state: BackupState,
     snackbarHostState: SnackbarHostState,
     onEvent: (BackupEvent) -> Unit,
 ) {
     if (state.showRestoreConfirmation) {
         RestoreBackupDialog(
             onConfirm = { onEvent(BackupEvent.OnRestoreConfirmed) },
             onDismiss = { onEvent(BackupEvent.OnRestoreDismissed) },
         )
     }
     if (state.showExportOptions) {
         ExportBackupDialog(
             onConfirm = { passphrase -> onEvent(BackupEvent.OnExportOptionsConfirmed(passphrase)) },
             onDismiss = { onEvent(BackupEvent.OnExportOptionsDismissed) },
         )
     }
     if (state.showImportPassphrase) {
         ImportPassphraseDialog(
             onConfirm = { passphrase -> onEvent(BackupEvent.OnImportPassphraseSubmitted(passphrase)) },
             onDismiss = { onEvent(BackupEvent.OnImportPassphraseDismissed) },
         )
     }
 
     SettingsSubScreenScaffold(
         title = stringResource(Res.string.settings_backup_title),
         snackbarHostState = snackbarHostState,
         onBack = { onEvent(BackupEvent.OnBackClick) },
         showProgressOverlay = state.phase != BackupPhase.Idle,
     ) { padding ->
         Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
             SurferStatusHeroCard(
                 title = stringResource(
                     if (state.isOffline) {
                         Res.string.settings_backup_local_hero_title
                     } else {
                         Res.string.settings_backup_hero_title
                     },
                 ),
                 supporting = stringResource(
                     if (state.isOffline) {
                         Res.string.settings_backup_local_hero_supporting
                     } else {
                         Res.string.settings_backup_hero_supporting
                     },
                 ),
                 icon = SurferIcons.Archive,
                 tone = SurferStatusHeroTone.Tertiary,
             )
         }
 
         SurferSettingsGroup(title = stringResource(Res.string.settings_backup_section_manual)) {
             SurferSettingsRow(
                 icon = SurferIcons.Download,
                 title = stringResource(Res.string.settings_backup_download_title),
                 supportingText = stringResource(Res.string.settings_backup_download_supporting),
                 onClick = { onEvent(BackupEvent.OnDownloadClick) },
                 trailing = { SurferSettingsChevron() },
             )
         }
 
         SurferSettingsGroup(title = stringResource(Res.string.settings_backup_section_restore)) {
             SurferSettingsRow(
                 icon = SurferIcons.Sync,
                 title = stringResource(Res.string.settings_backup_restore_title),
                 supportingText = stringResource(Res.string.settings_backup_restore_supporting),
                 onClick = { onEvent(BackupEvent.OnRestoreClick) },
                 trailing = { SurferSettingsChevron() },
             )
         }
 
         Spacer(Modifier.height(padding.calculateBottomPadding() + 32.dp))
     }
 }
 
 /**
  * Pre-export options: warns that a plain export is readable by anyone holding
  * the file, and offers an optional passphrase (with confirmation to guard
  * against a typo locking the user out of their own backup).
  */
 @Composable
 private fun ExportBackupDialog(
     onConfirm: (String?) -> Unit,
     onDismiss: () -> Unit,
 ) {
     var passphrase by remember { mutableStateOf("") }
     var confirmation by remember { mutableStateOf("") }
     val mismatch = passphrase.isNotEmpty() && confirmation != passphrase
 
     AlertDialog(
         onDismissRequest = onDismiss,
         title = { Text(stringResource(Res.string.settings_backup_download_title)) },
         text = {
             Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                 Text(stringResource(Res.string.settings_backup_export_warning))
                 OutlinedTextField(
                     value = passphrase,
                     onValueChange = { passphrase = it },
                     label = { Text(stringResource(Res.string.settings_backup_export_passphrase_label)) },
                     singleLine = true,
                     visualTransformation = PasswordVisualTransformation(),
                     keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                 )
                 if (passphrase.isNotEmpty()) {
                     OutlinedTextField(
                         value = confirmation,
                         onValueChange = { confirmation = it },
                         label = {
                             Text(
                                 stringResource(
                                     Res.string.settings_backup_export_passphrase_confirm_label,
                                 ),
                             )
                         },
                         singleLine = true,
                         visualTransformation = PasswordVisualTransformation(),
                         keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                         isError = mismatch,
                         supportingText = {
                             if (mismatch) {
                                 Text(
                                     stringResource(
                                         Res.string.settings_backup_export_passphrase_mismatch,
                                     ),
                                 )
                             }
                         },
                     )
                 }
             }
         },
         confirmButton = {
             TextButton(
                 enabled = !mismatch,
                 onClick = { onConfirm(passphrase.takeIf { it.isNotEmpty() }) },
             ) {
                 Text(stringResource(Res.string.settings_backup_export_confirm))
             }
         },
         dismissButton = {
             TextButton(onClick = onDismiss) {
                 Text(stringResource(Res.string.settings_backup_cancel))
             }
         },
     )
 }
 
 @Composable
 private fun ImportPassphraseDialog(
     onConfirm: (String) -> Unit,
     onDismiss: () -> Unit,
 ) {
     var passphrase by remember { mutableStateOf("") }
 
     AlertDialog(
         onDismissRequest = onDismiss,
         title = { Text(stringResource(Res.string.settings_backup_import_passphrase_title)) },
         text = {
             Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                 Text(stringResource(Res.string.settings_backup_import_passphrase_body))
                 OutlinedTextField(
                     value = passphrase,
                     onValueChange = { passphrase = it },
                     label = { Text(stringResource(Res.string.settings_backup_import_passphrase_label)) },
                     singleLine = true,
                     visualTransformation = PasswordVisualTransformation(),
                     keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                 )
             }
         },
         confirmButton = {
             TextButton(
                 enabled = passphrase.isNotEmpty(),
                 onClick = { onConfirm(passphrase) },
             ) {
                 Text(stringResource(Res.string.settings_backup_import_passphrase_confirm))
             }
         },
         dismissButton = {
             TextButton(onClick = onDismiss) {
                 Text(stringResource(Res.string.settings_backup_cancel))
             }
         },
     )
 }
 
 /**
  * Shown on hosts whose [AppRestarter] can only exit the process (iOS). The
  * import already succeeded at this point and the in-memory Room handles are
  * closed, so there is nothing to go back to — the dialog is not dismissible
  * and its only button ends the process.
  */
 @Composable
 private fun RelaunchNoticeDialog(onConfirm: () -> Unit) {
     AlertDialog(
         onDismissRequest = {},
         title = { Text(stringResource(Res.string.settings_backup_relaunch_title)) },
         text = { Text(stringResource(Res.string.settings_backup_relaunch_body)) },
         confirmButton = {
             TextButton(onClick = onConfirm) {
                 Text(stringResource(Res.string.settings_backup_relaunch_confirm))
             }
         },
     )
 }
 
 @Composable
 private fun RestoreBackupDialog(
     onConfirm: () -> Unit,
     onDismiss: () -> Unit,
 ) {
     AlertDialog(
         onDismissRequest = onDismiss,
         title = { Text(stringResource(Res.string.settings_backup_restore_confirm_title)) },
         text = { Text(stringResource(Res.string.settings_backup_restore_confirm_body)) },
         confirmButton = {
             TextButton(onClick = onConfirm) {
                 Text(
                     text = stringResource(Res.string.settings_backup_restore_confirm_confirm),
                     color = AppTheme.materialColors.error,
                 )
             }
         },
         dismissButton = {
             TextButton(onClick = onDismiss) {
                 Text(stringResource(Res.string.settings_backup_cancel))
             }
         },
     )
 }