Backup & restore
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user