diff --git a/app-backup/build.gradle b/app-backup/build.gradle index 19b73d25..2767ff3a 100644 --- a/app-backup/build.gradle +++ b/app-backup/build.gradle @@ -7,20 +7,31 @@ plugins { android { namespace 'xyz.aprildown.timer.app.backup' + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } } dependencies { implementation project(':app-base') implementation project(':component-key') + testImplementation libs.junit + testImplementation libs.kotlin.coroutines.test + testImplementation libs.bundles.mockito.core + implementation libs.androidx.preference + implementation platform(libs.compose.bom) + implementation libs.bundles.compose + implementation libs.hilt.android kapt libs.hilt.compiler implementation libs.okio implementation libs.permission - implementation libs.verticalStepperForm - } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/AbstractStep.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/AbstractStep.kt deleted file mode 100644 index 7b8ab8bd..00000000 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/AbstractStep.kt +++ /dev/null @@ -1,21 +0,0 @@ -package xyz.aprildown.timer.app.backup - -import ernestoyaquello.com.verticalstepperform.Step - -internal abstract class AbstractStep( - title: String, - subTitle: String = "", - nextButtonText: String = "" -) : Step(title, subTitle, nextButtonText) { - override fun restoreStepData(data: T) = Unit - - override fun onStepMarkedAsCompleted(animated: Boolean) = Unit - - override fun onStepOpened(animated: Boolean) = Unit - - override fun onStepMarkedAsUncompleted(animated: Boolean) = Unit - - override fun onStepClosed(animated: Boolean) = Unit - - override fun getStepDataAsHumanReadableString(): String = "" -} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt new file mode 100644 index 00000000..bc42d33c --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt @@ -0,0 +1,250 @@ +package xyz.aprildown.timer.app.backup + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.NavigateNext +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import xyz.aprildown.timer.domain.usecases.Fruit +import xyz.aprildown.timer.app.base.R as RBase + +@Composable +internal fun Backup( + screen: BaseBackupViewModel.Screen<*>, + contentLocationTitle: String, + contentLocationButtonText: String, + onChangeContentLocation: () -> Unit, + backupButtonText: String, + backupErrorHint: String, + modifier: Modifier = Modifier, + extraOptions: @Composable () -> Unit = {}, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Header(text = contentLocationTitle) + + ContentLocation( + title = contentLocationButtonText, + location = screen.contentName, + modifier = Modifier.clickable(onClick = onChangeContentLocation), + ) + + Header(text = stringResource(id = RBase.string.backup_app_data)) + + extraOptions() + + DataEntry( + iconRes = RBase.drawable.ic_timer, + nameRes = RBase.string.main_action_timers, + include = screen.includeTimers, + onIncludedChange = screen.onTimersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_scheduler, + nameRes = RBase.string.main_action_schedulers, + include = screen.includeSchedulers, + onIncludedChange = screen.onSchedulersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_stat, + nameRes = RBase.string.main_action_record, + include = screen.includeRecords, + onIncludedChange = screen.onRecordsChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_settings, + nameRes = RBase.string.main_action_settings, + include = screen.includeSettings, + onIncludedChange = screen.onSettingsChange, + ) + + Button( + onClick = screen.onBackup, + modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = screen.content != null && + (screen.includeTimers || screen.includeSettings) && + !screen.backupOngoing, + ) { + Text(text = backupButtonText) + } + } + + if (screen.backupResult is Fruit.Rotten) { + BackupError( + hint = backupErrorHint, + message = screen.backupResult.exception.message.toString(), + consume = screen.consumeBackupError, + ) + } + + if (screen.backupOngoing) { + BackupOngoingDialog() + } +} + +@Composable +private fun Header( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.padding(horizontal = 12.dp, vertical = 12.dp), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleSmall, + ) +} + +@Composable +private fun ContentLocation( + title: String, + location: String?, + modifier: Modifier = Modifier, +) { + if (location.isNullOrBlank()) { + ContentLocationEmpty(title = title, modifier = modifier) + } else { + ContentLocationPath(title = title, location = location, modifier = modifier) + } +} + +@Composable +private fun ContentLocationPath( + title: String, + location: String, + modifier: Modifier +) { + ListItem( + headlineContent = { + Text(text = location) + }, + modifier = modifier, + overlineContent = { + Text(text = title) + }, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) +} + +@Composable +private fun ContentLocationEmpty( + title: String, + modifier: Modifier +) { + ListItem( + headlineContent = { + Text(text = title) + }, + modifier = modifier, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) +} + +@Composable +private fun DataEntry( + @DrawableRes iconRes: Int, + @StringRes nameRes: Int, + include: Boolean, + onIncludedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text(text = stringResource(id = nameRes)) + }, + modifier = modifier + .toggleable( + value = include, + role = Role.Switch, + onValueChange = onIncludedChange, + ), + leadingContent = { + Icon(painter = painterResource(id = iconRes), contentDescription = null) + }, + trailingContent = { + Switch(checked = include, onCheckedChange = null) + }, + ) +} + +@Composable +private fun BackupError( + hint: String, + message: String, + consume: () -> Unit, +) { + AlertDialog( + onDismissRequest = consume, + confirmButton = { + TextButton(onClick = consume) { + Text(text = stringResource(id = RBase.string.ok)) + } + }, + text = { + Text( + text = buildString { + append(hint) + + if (message.isNotBlank()) { + append("\n\n") + append(message) + } + }, + ) + } + ) +} + +@Composable +private fun BackupOngoingDialog() { + Dialog(onDismissRequest = {}) { + Surface(shape = MaterialTheme.shapes.medium) { + CircularProgressIndicator(modifier = Modifier.padding(24.dp)) + } + } +} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt new file mode 100644 index 00000000..a613bbd4 --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt @@ -0,0 +1,127 @@ +package xyz.aprildown.timer.app.backup + +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import xyz.aprildown.timer.domain.usecases.Fruit +import xyz.aprildown.timer.presentation.BaseViewModel + +internal abstract class BaseBackupViewModel( + mainDispatcher: CoroutineDispatcher, +) : BaseViewModel(mainDispatcher) { + @Immutable + data class Screen( + val content: ContentType? = null, + val contentName: String? = null, + + val includeTimers: Boolean = true, + val onTimersChange: (Boolean) -> Unit, + + val includeRecords: Boolean = true, + val onRecordsChange: (Boolean) -> Unit, + + val includeSchedulers: Boolean = true, + val onSchedulersChange: (Boolean) -> Unit, + + val includeSettings: Boolean = true, + val onSettingsChange: (Boolean) -> Unit, + + val onBackup: () -> Unit, + val backupOngoing: Boolean = false, + + val backupResult: Fruit? = null, + val consumeBackupError: () -> Unit, + ) { + interface Content + } + + private val _screen: MutableStateFlow> = MutableStateFlow( + Screen( + onTimersChange = ::onTimersChange, + onRecordsChange = ::onRecordsChange, + onSchedulersChange = ::onSchedulersChange, + onSettingsChange = ::onSettingsChange, + onBackup = ::onBackup, + consumeBackupError = ::consumeBackupError, + ) + ) + val screen: StateFlow> = _screen.asStateFlow() + + fun changeContent(content: ContentType, name: String?) { + _screen.update { it.copy(content = content, contentName = name) } + } + + private fun onTimersChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = include, + includeRecords = include, + includeSchedulers = include, + ) + } + } + + private fun onRecordsChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = if (include) true else it.includeTimers, + includeRecords = include, + ) + } + } + + private fun onSchedulersChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = if (include) true else it.includeTimers, + includeSchedulers = include, + ) + } + } + + private fun onSettingsChange(include: Boolean) { + _screen.update { + it.copy(includeSettings = include) + } + } + + protected abstract suspend fun backup(screen: Screen) + + private fun onBackup() { + val screen = _screen.value + if (screen.content == null) return + if (!screen.includeTimers && !screen.includeSettings) return + if (screen.backupOngoing) return + + launch { + _screen.update { it.copy(backupOngoing = true) } + try { + backup(screen) + _screen.update { + it.copy( + backupOngoing = false, + backupResult = Fruit.Ripe(Unit), + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _screen.update { + it.copy( + backupOngoing = false, + backupResult = Fruit.Rotten(e), + ) + } + } + } + } + + private fun consumeBackupError() { + _screen.update { it.copy(backupResult = null) } + } +} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt index 08d579b6..a1941959 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt @@ -3,35 +3,34 @@ package xyz.aprildown.timer.app.backup import android.app.Activity import android.content.Context import android.content.Intent -import android.graphics.Color import android.net.Uri import android.os.Bundle -import android.provider.OpenableColumns +import android.view.LayoutInflater import android.view.View -import android.widget.Button -import android.widget.TextView +import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.database.getStringOrNull -import androidx.core.text.buildSpannedString +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController import com.github.deweyreed.tools.anko.longSnackbar -import com.github.deweyreed.tools.arch.observeNonNull -import com.github.deweyreed.tools.helper.gone import com.github.deweyreed.tools.helper.requireCallback -import com.github.deweyreed.tools.helper.show -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import ernestoyaquello.com.verticalstepperform.VerticalStepperFormView -import ernestoyaquello.com.verticalstepperform.listener.StepperFormListener +import okio.sink import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri +import xyz.aprildown.timer.app.base.ui.AppTheme import xyz.aprildown.timer.app.base.ui.MainCallback -import xyz.aprildown.timer.app.base.ui.newDynamicTheme import xyz.aprildown.timer.domain.usecases.Fruit -import xyz.aprildown.timer.domain.usecases.data.ExportAppData import xyz.aprildown.timer.domain.utils.AppTracker -import xyz.aprildown.timer.presentation.backup.ExportViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -39,17 +38,14 @@ import javax.inject.Inject import xyz.aprildown.timer.app.base.R as RBase @AndroidEntryPoint -class ExportFragment : Fragment(R.layout.layout_vertical_form), StepperFormListener { +class ExportFragment : Fragment() { private val viewModel: ExportViewModel by viewModels() - @Inject - lateinit var appTracker: AppTracker - private lateinit var mainCallback: MainCallback.ActivityCallback - private lateinit var locationStep: ExportLocationStep - private lateinit var contentStep: ExportContentStep + @Inject + lateinit var appTracker: AppTracker private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -61,83 +57,39 @@ class ExportFragment : Fragment(R.layout.layout_vertical_form), StepperFormListe mainCallback = requireCallback() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val context = view.context - val form = view as VerticalStepperFormView - locationStep = ExportLocationStep(resources.getString(RBase.string.export_path_title), this) - contentStep = ExportContentStep(resources.getString(RBase.string.export_content_title)) - form.setup(this, locationStep, contentStep) - .displayBottomNavigation(false) - .apply { - newDynamicTheme.run { - basicColorScheme(colorPrimary, colorPrimaryVariant, colorOnPrimary) - nextButtonColors(colorSecondary, colorSecondary, Color.WHITE, Color.WHITE) - } - } - .stepNextButtonText(getString(RBase.string.backup_next_step)) - .confirmationStepTitle(resources.getString(RBase.string.export_begin)) - .lastStepNextButtonText(getString(RBase.string.export_action)) - .displayCancelButtonInLastStep(true) - .lastStepCancelButtonText(getString(android.R.string.cancel)) - .init() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + } - viewModel.result.observeNonNull(viewLifecycleOwner) { fruit -> - when (fruit) { - is Fruit.Ripe -> { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val navController = findNavController() + (view as ComposeView).setContent { + val screen by viewModel.screen.collectAsState() + LaunchedEffect(screen.backupResult) { + if (screen.backupResult is Fruit.Ripe) { mainCallback.snackbarView.longSnackbar(RBase.string.export_done) - popBackToBackup() - } - is Fruit.Rotten -> { - val exception = fruit.exception - MaterialAlertDialogBuilder(context) - .setMessage( - buildSpannedString { - append(getText(RBase.string.export_error)) - - (exception.localizedMessage ?: exception.message) - ?.takeIf { it.isNotBlank() } - ?.let { message -> - append("\n\n") - append(message) - } - } - ) - .setPositiveButton(android.R.string.ok, null) - .setOnDismissListener { - popBackToBackup() - } - .show() + navController.popBackStack( + destinationId = RBase.id.dest_backup_restore, + inclusive = false, + ) } } - viewModel.consumeResult() - } - } - - override fun onCompletedForm() { - val context = requireContext() - val settings = contentStep.stepData - viewModel.export( - params = ExportAppData.Params( - exportTimers = settings.isTimersChecked, - exportTimerStamps = settings.isTimerStampsChecked, - exportSchedulers = settings.isSchedulersChecked, - exportPreferences = settings.isSettingsChecked - ), - outputStream = checkNotNull( - context.contentResolver.openOutputStream(locationStep.stepData, "rwt") + Export( + screen = screen, + onLocationChange = ::onLocationChange, + modifier = Modifier.fillMaxSize(), ) - ) - } - - override fun onCancelledForm() { - popBackToBackup() - } - - private fun popBackToBackup() { - NavHostFragment.findNavController(this).popBackStack(RBase.id.dest_backup_restore, false) + } } - fun pickExportPath() { + private fun onLocationChange() { val date = Date() val timeString = SimpleDateFormat("yyyy-MM-dd-kk-mm", Locale.getDefault()).format(date) val initialFilename = "timer-machine-$timeString.json" @@ -155,101 +107,43 @@ class ExportFragment : Fragment(R.layout.layout_vertical_form), StepperFormListe } private fun handleActivityResult(resultCode: Int, data: Intent?) { - if (resultCode == Activity.RESULT_OK) { - val uri = data?.data ?: return - val context = requireContext() - var name: String? = null - - try { - context.contentResolver.query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - name = - cursor.getStringOrNull(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) - } - } - } catch (e: Exception) { - appTracker.trackError(e) - mainCallback.snackbarView.longSnackbar(e.message.toString()) - return - } - - context.lastBackupUri = uri - locationStep.locationPicked(uri, name) - } - } -} - -private class ExportLocationStep( - title: String, - private val parentFragment: ExportFragment -) : AbstractStep(title) { - - private var locationUri: Uri = Uri.EMPTY - private lateinit var locationTextView: TextView - - override fun createStepContentLayout(): View { - val context = context - val view = View.inflate(context, R.layout.step_text_button, null) - locationTextView = view.findViewById(R.id.textStepTextButton).apply { - gone() - } - view.findViewById