Backup & restore

This commit is contained in:
MM20
2022-06-09 19:26:45 +02:00
parent c7fb8bf57a
commit eaf4701500
37 changed files with 1660 additions and 29 deletions
@@ -0,0 +1,214 @@
package de.mm20.launcher2.ui.common
import android.net.Uri
import android.text.format.DateUtils
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.backup.BackupCompatibility
import de.mm20.launcher2.backup.BackupComponent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.SmallMessage
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RestoreBackupSheet(
uri: Uri,
onDismissRequest: () -> Unit
) {
val viewModel: RestoreBackupSheetVM = viewModel()
LaunchedEffect(uri) {
viewModel.setInputUri(uri)
}
val state by viewModel.state.observeAsState(RestoreBackupState.Parsing)
val selectedComponents by viewModel.selectedComponents.observeAsState(emptySet())
val compatibility by viewModel.compatibility.observeAsState(null)
BottomSheetDialog(
onDismissRequest = onDismissRequest,
title = {
Text(
stringResource(id = R.string.preference_restore),
)
},
confirmButton = {
if (state == RestoreBackupState.Ready && compatibility != BackupCompatibility.Incompatible) {
Button(
enabled = selectedComponents.isNotEmpty(),
onClick = { viewModel.restore() }) {
Text(stringResource(R.string.preference_restore))
}
} else if (state == RestoreBackupState.InvalidFile || state == RestoreBackupState.Restored) {
OutlinedButton(
onClick = onDismissRequest
) {
Text(stringResource(R.string.close))
}
}
}
) {
when (state) {
RestoreBackupState.Parsing -> {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
}
}
RestoreBackupState.InvalidFile -> {
LargeMessage(
modifier = Modifier.aspectRatio(1f),
icon = Icons.Rounded.ErrorOutline,
text = stringResource(id = R.string.restore_invalid_file)
)
}
RestoreBackupState.Ready -> {
val metadata by viewModel.metadata.observeAsState(null)
if (metadata != null) {
Column {
SmallMessage(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(bottom = 16.dp),
icon = Icons.Rounded.Info,
text = stringResource(
R.string.restore_meta,
DateUtils.formatDateTime(
LocalContext.current,
metadata!!.timestamp,
DateUtils.FORMAT_ABBREV_ALL or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR
),
metadata!!.deviceName,
stringResource(R.string.app_name) + " " + metadata!!.appVersionName,
)
)
if (compatibility == BackupCompatibility.Incompatible) {
LargeMessage(
modifier = Modifier.aspectRatio(1f),
icon = Icons.Rounded.ErrorOutline,
text = stringResource(
id = R.string.restore_incompatible_file,
stringResource(R.string.app_name)
)
)
} else {
if (compatibility == BackupCompatibility.PartiallyCompatible) {
SmallMessage(
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
icon = Icons.Rounded.Warning,
text =
stringResource(
R.string.restore_different_minor_version,
stringResource(R.string.app_name)
)
)
}
Text(
stringResource(R.string.restore_select_components),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
val components by viewModel.availableComponents.observeAsState(emptyList())
for (component in components) {
Row(
modifier = Modifier
.clickable {
viewModel.toggleComponent(
component
)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (component) {
BackupComponent.Favorites -> Icons.Rounded.Star
BackupComponent.Settings -> Icons.Rounded.Settings
BackupComponent.Websearches -> Icons.Rounded.TravelExplore
BackupComponent.Widgets -> Icons.Rounded.Widgets
},
contentDescription = null
)
Text(
text = stringResource(
when (component) {
BackupComponent.Favorites -> R.string.backup_component_favorites
BackupComponent.Settings -> R.string.backup_component_settings
BackupComponent.Websearches -> R.string.backup_component_websearches
BackupComponent.Widgets -> R.string.backup_component_widgets
}
),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
)
Checkbox(
checked = selectedComponents.contains(
component
),
onCheckedChange = {
viewModel.toggleComponent(component)
}
)
}
}
}
}
}
}
RestoreBackupState.Restoring -> {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
}
}
RestoreBackupState.Restored -> {
LargeMessage(
modifier = Modifier.aspectRatio(1f),
icon = Icons.Rounded.CheckCircleOutline,
text = stringResource(
id = R.string.restore_complete
)
)
}
}
}
}
@@ -0,0 +1,73 @@
package de.mm20.launcher2.ui.common
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.backup.BackupCompatibility
import de.mm20.launcher2.backup.BackupComponent
import de.mm20.launcher2.backup.BackupManager
import de.mm20.launcher2.backup.BackupMetadata
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class RestoreBackupSheetVM : ViewModel(), KoinComponent {
private val backupManager: BackupManager by inject()
private var restoreUri: Uri? = null
val state = MutableLiveData(RestoreBackupState.Parsing)
val metadata = MutableLiveData<BackupMetadata?>(null)
val compatibility = MutableLiveData<BackupCompatibility?>(null)
val selectedComponents = MutableLiveData(setOf<BackupComponent>())
val availableComponents = MutableLiveData(emptyList<BackupComponent>())
fun setInputUri(uri: Uri) {
restoreUri = uri
state.value = RestoreBackupState.Parsing
viewModelScope.launch {
val metadata = backupManager.readBackupMeta(uri)
if (metadata == null) {
state.value = RestoreBackupState.InvalidFile
availableComponents.value = emptyList()
} else {
state.value = RestoreBackupState.Ready
compatibility.value = backupManager.checkCompatibility(metadata)
availableComponents.value = metadata.components.toList().sortedBy { it.ordinal }
}
selectedComponents.value = metadata?.components ?: emptySet()
this@RestoreBackupSheetVM.metadata.value = metadata
}
}
fun toggleComponent(component: BackupComponent) {
val components = selectedComponents.value ?: emptySet()
if (components.contains(component)) {
selectedComponents.value = components - component
} else {
selectedComponents.value = components + component
}
}
fun restore() {
val components = selectedComponents.value ?: return
val uri = restoreUri ?: return
viewModelScope.launch {
state.value = RestoreBackupState.Restoring
backupManager.restore(uri, components)
state.value = RestoreBackupState.Restored
}
}
}
enum class RestoreBackupState {
Parsing,
InvalidFile,
Ready,
Restoring,
Restored,
}
@@ -0,0 +1,222 @@
package de.mm20.launcher2.ui.component
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class)
@Composable
fun BottomSheetDialog(
onDismissRequest: () -> Unit,
title: @Composable () -> Unit,
confirmButton: @Composable (() -> Unit)? = null,
dismissButton: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val scrollState = rememberScrollState()
val swipeState = remember {
SwipeableState(
initialValue = SwipeState.Dismiss,
confirmStateChange = {
if (it == SwipeState.Dismiss) onDismissRequest()
return@SwipeableState true
}
)
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0) {
return super.onPreScroll(available, source)
}
val c = swipeState.performDrag(available.y)
return Offset(available.x, c)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (available.y < 0) {
return super.onPreScroll(available, source)
}
val c = swipeState.performDrag(available.y)
return Offset(available.x, c)
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (available.y > 0) {
return super.onPreFling(available)
}
swipeState.performFling(available.y)
return available
}
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity
): Velocity {
if (available.y < 0) {
return super.onPreFling(available)
}
swipeState.performFling(available.y)
return available
}
}
}
Dialog(
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = true
),
onDismissRequest = onDismissRequest,
) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
propagateMinConstraints = true,
contentAlignment = Alignment.BottomCenter
) {
val maxHeightPx = maxHeight.toPixels()
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
.clipToBounds(),
verticalArrangement = Arrangement.Bottom
) {
var height by remember {
mutableStateOf(maxHeightPx)
}
LaunchedEffect(null) {
swipeState.animateTo(SwipeState.Peek)
}
val heightDp = height.toDp()
val peekHeight = (height - maxHeightPx / 2).coerceAtLeast(0f)
val anchors = mutableMapOf(
peekHeight to SwipeState.Peek,
height to SwipeState.Dismiss,
).also {
if (peekHeight > 0f) {
it[0f] = SwipeState.Full
}
}
Surface(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
.swipeable(
swipeState,
anchors = anchors,
orientation = Orientation.Vertical,
thresholds = { _, to ->
if (to == SwipeState.Dismiss) {
FixedThreshold(heightDp - 48.dp)
} else {
FractionalThreshold(0.5f)
}
},
resistance = null
)
//.animateContentSize()
.onSizeChanged {
height = it.height.toFloat()
}
.offset { IntOffset(0, swipeState.offset.value.roundToInt()) }
.fillMaxWidth()
.weight(1f, false),
shape = MaterialTheme.shapes.large.copy(
bottomStart = CornerSize(0),
bottomEnd = CornerSize(0),
),
shadowElevation = 16.dp,
) {
Column {
CenterAlignedTopAppBar(
title = title
)
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.verticalScroll(scrollState)
.padding(horizontal = 24.dp, vertical = 8.dp),
propagateMinConstraints = true,
contentAlignment = Alignment.Center
) {
content()
}
}
}
val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp)
Surface(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth(),
tonalElevation = elevation,
) {
if (confirmButton != null || dismissButton != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.End
) {
if (dismissButton != null) {
dismissButton()
}
if (confirmButton != null && dismissButton != null) {
Spacer(modifier = Modifier.width(16.dp))
}
if (confirmButton != null) {
confirmButton()
}
}
}
}
}
}
}
}
private enum class SwipeState {
Full, Peek, Dismiss
}
@@ -0,0 +1,41 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun LargeMessage(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(bottom = 24.dp)
.size(64.dp)
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,41 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmallMessage(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String,
color: Color = MaterialTheme.colorScheme.surfaceVariant
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = color
)
) {
Row(
modifier = Modifier
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = icon, contentDescription = null)
Text(
text = text,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.labelSmall
)
}
}
}
@@ -1,6 +1,6 @@
package de.mm20.launcher2.ui.ktx
import androidx.compose. animation.core.Animatable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.VectorConverter
@@ -60,6 +60,7 @@ fun HiddenItemsSheet(
}
}
val swipeState =
rememberSwipeableState(initialValue = SwipeState.Default) {
if (it == SwipeState.Dismiss) onDismiss()
@@ -23,6 +23,7 @@ import de.mm20.launcher2.ui.locals.LocalNavController
import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
import de.mm20.launcher2.ui.settings.accounts.AccountsSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.badges.BadgeSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
import de.mm20.launcher2.ui.settings.calendarwidget.CalendarWidgetSettingsScreen
@@ -149,6 +150,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/debug") {
DebugSettingsScreen()
}
composable("settings/backup") {
BackupSettingsScreen()
}
composable("settings/debug/crashreporter") {
CrashReporterScreen()
}
@@ -0,0 +1,66 @@
package de.mm20.launcher2.ui.settings.backup
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.common.RestoreBackupSheet
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@Composable
fun BackupSettingsScreen() {
val viewModel: BackupSettingsScreenVM = viewModel()
val restoreUri by viewModel.restoreUri.observeAsState()
val showBackupSheet by viewModel.showBackupSheet.observeAsState(false)
val context = LocalContext.current
val restoreLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = {
viewModel.setRestoreUri(it)
}
)
PreferenceScreen(stringResource(R.string.preference_screen_backup)) {
item {
PreferenceCategory {
Preference(
title = stringResource(id = R.string.preference_backup),
summary = stringResource(id = R.string.preference_backup_summary),
onClick = {
viewModel.setShowBackupSheet(true)
})
Preference(
title = stringResource(id = R.string.preference_restore),
summary = stringResource(id = R.string.preference_restore_summary),
onClick = {
restoreLauncher.launch(arrayOf("*/*"))
})
}
}
}
val uri = restoreUri
if (uri != null) {
RestoreBackupSheet(uri = uri, onDismissRequest = { viewModel.setRestoreUri(null) })
}
if(showBackupSheet) {
CreateBackupSheet(onDismissRequest = {
viewModel.setShowBackupSheet(false)
})
}
}
@@ -0,0 +1,31 @@
package de.mm20.launcher2.ui.settings.backup
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.backup.BackupManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
class BackupSettingsScreenVM : ViewModel(), KoinComponent {
val showBackupSheet = MutableLiveData(false)
val restoreUri = MutableLiveData<Uri?>(null)
fun setShowBackupSheet(show: Boolean) {
showBackupSheet.value = show
}
fun setRestoreUri(uri: Uri?) {
restoreUri.value = uri
}
}
@@ -0,0 +1,184 @@
package de.mm20.launcher2.ui.settings.backup
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.backup.BackupComponent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.SmallMessage
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@Composable
fun CreateBackupSheet(
onDismissRequest: () -> Unit
) {
val viewModel: CreateBackupSheetVM = viewModel()
LaunchedEffect(null) {
viewModel.reset()
}
val components by viewModel.selectedComponents.observeAsState(emptySet())
val state by viewModel.state.observeAsState(CreateBackupState.Ready)
val backupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/vendor.de.mm20.launcher2.backup"),
onResult = {
if (it != null) viewModel.createBackup(it)
}
)
BottomSheetDialog(onDismissRequest = onDismissRequest,
title = {
Text(
stringResource(id = R.string.preference_backup),
)
},
confirmButton = {
if (state == CreateBackupState.Ready) {
Button(
enabled = components.isNotEmpty(),
onClick = {
val fileName = "${
ZonedDateTime.now().format(
DateTimeFormatter.ISO_INSTANT
)
}.kvaesitso"
backupLauncher.launch(fileName)
}) {
Text(stringResource(R.string.preference_backup))
}
} else if (state == CreateBackupState.BackedUp) {
OutlinedButton(
onClick = onDismissRequest
) {
Text(stringResource(R.string.close))
}
}
}
) {
when (state) {
CreateBackupState.Ready -> {
Column {
Text(
stringResource(R.string.backup_select_components),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
BackupableComponent(
title = stringResource(R.string.backup_component_settings),
icon = Icons.Rounded.Settings,
checked = components.contains(BackupComponent.Settings),
onCheckedChange = {
viewModel.toggleComponent(BackupComponent.Settings)
}
)
BackupableComponent(
title = stringResource(R.string.backup_component_favorites),
icon = Icons.Rounded.Star,
checked = components.contains(BackupComponent.Favorites),
onCheckedChange = {
viewModel.toggleComponent(BackupComponent.Favorites)
}
)
BackupableComponent(
title = stringResource(R.string.backup_component_widgets),
icon = Icons.Rounded.Widgets,
checked = components.contains(BackupComponent.Widgets),
onCheckedChange = {
viewModel.toggleComponent(BackupComponent.Widgets)
}
)
BackupableComponent(
title = stringResource(R.string.backup_component_websearches),
icon = Icons.Rounded.TravelExplore,
checked = components.contains(BackupComponent.Websearches),
onCheckedChange = {
viewModel.toggleComponent(BackupComponent.Websearches)
}
)
SmallMessage(
modifier = Modifier.padding(top = 8.dp),
icon = Icons.Rounded.Warning,
text = "Connected accounts and 3rd party app widgets will not be backed up."
)
}
}
CreateBackupState.BackingUp -> {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
}
}
CreateBackupState.BackedUp -> {
LargeMessage(
modifier = Modifier.aspectRatio(1f),
icon = Icons.Rounded.CheckCircleOutline,
text = stringResource(
id = R.string.backup_complete
)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackupableComponent(
title: String,
icon: ImageVector,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.clickable {
onCheckedChange(!checked)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}
@@ -0,0 +1,49 @@
package de.mm20.launcher2.ui.settings.backup
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.backup.BackupComponent
import de.mm20.launcher2.backup.BackupManager
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class CreateBackupSheetVM : ViewModel(), KoinComponent {
private val backupManager: BackupManager by inject()
val state = MutableLiveData(CreateBackupState.Ready)
val selectedComponents = MutableLiveData(BackupComponent.values().toSet())
fun reset() {
state.value = CreateBackupState.Ready
}
fun createBackup(uri: Uri) {
val components = selectedComponents.value ?: return
viewModelScope.launch {
state.value = CreateBackupState.BackingUp
backupManager.backup(uri, components)
state.value = CreateBackupState.BackedUp
}
}
fun toggleComponent(component: BackupComponent) {
val components = selectedComponents.value ?: emptySet()
if (components.contains(component)) {
selectedComponents.value = components - component
} else {
selectedComponents.value = components + component
}
}
}
enum class CreateBackupState {
Ready,
BackingUp,
BackedUp,
}
@@ -62,6 +62,14 @@ fun MainSettingsScreen() {
navController?.navigate("settings/accounts")
}
)
Preference(
icon = Icons.Rounded.SettingsBackupRestore,
title = stringResource(id = R.string.preference_screen_backup),
summary = stringResource(id = R.string.preference_screen_backup_summary),
onClick = {
navController?.navigate("settings/backup")
}
)
Preference(
icon = Icons.Rounded.BugReport,
title = stringResource(id = R.string.preference_screen_debug),