Add note widget file sync
This commit is contained in:
parent
0280de4bbc
commit
bbe96ed09a
@ -29,6 +29,7 @@ import androidx.compose.material.FixedThreshold
|
||||
import androidx.compose.material.FractionalThreshold
|
||||
import androidx.compose.material.SwipeableState
|
||||
import androidx.compose.material.swipeable
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.LocalAbsoluteTonalElevation
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -65,8 +66,8 @@ import kotlin.math.roundToInt
|
||||
@Composable
|
||||
fun BottomSheetDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
title: @Composable () -> Unit,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
title: (@Composable () -> Unit)? = null,
|
||||
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||
confirmButton: @Composable (() -> Unit)? = null,
|
||||
dismissButton: @Composable (() -> Unit)? = null,
|
||||
dismissible: () -> Boolean = { true },
|
||||
@ -227,13 +228,21 @@ fun BottomSheetDialog(
|
||||
shadowElevation = 16.dp,
|
||||
) {
|
||||
Column {
|
||||
CenterAlignedTopAppBar(
|
||||
title = title,
|
||||
actions = actions,
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
if (title != null || actions != null) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = title ?: { BottomSheetDefaults.DragHandle() },
|
||||
actions = actions ?: {},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
BottomSheetDefaults.DragHandle()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@ -36,24 +36,25 @@ import org.intellij.markdown.parser.MarkdownParser
|
||||
fun MarkdownEditor(
|
||||
value: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
focus: Boolean,
|
||||
onFocusChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
val delimiterColor = MaterialTheme.colorScheme.secondary
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
|
||||
BackHandler(
|
||||
enabled = focused
|
||||
enabled = focus
|
||||
) {
|
||||
focused = false
|
||||
onFocusChange(false)
|
||||
}
|
||||
|
||||
if (focused) {
|
||||
if (focus) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
@ -119,7 +120,7 @@ fun MarkdownEditor(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
focused = true
|
||||
onFocusChange(true)
|
||||
},
|
||||
) {
|
||||
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
|
||||
@ -137,7 +138,7 @@ fun MarkdownEditor(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
focused = true
|
||||
onFocusChange(true)
|
||||
},
|
||||
onTextChange = { onValueChange(TextFieldValue(it)) },
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -14,10 +15,9 @@ import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Preference(
|
||||
title: String,
|
||||
icon: @Composable (() -> Unit),
|
||||
iconPadding: Boolean = true,
|
||||
summary: String? = null,
|
||||
title: @Composable (() -> Unit),
|
||||
summary: @Composable (() -> Unit)? = null,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
onClick: () -> Unit = {},
|
||||
controls: @Composable (() -> Unit)? = null,
|
||||
enabled: Boolean = true
|
||||
@ -30,7 +30,7 @@ fun Preference(
|
||||
.padding(horizontal = 16.dp)
|
||||
.alpha(if (enabled) 1f else 0.38f),
|
||||
) {
|
||||
if (iconPadding) {
|
||||
if (icon != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(56.dp)
|
||||
@ -43,15 +43,18 @@ fun Preference(
|
||||
Box(modifier = Modifier.size(0.dp))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(vertical = 16.dp)
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
ProvideTextStyle(value = MaterialTheme.typography.titleMedium) {
|
||||
title()
|
||||
}
|
||||
if (summary != null) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) {
|
||||
summary()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (controls != null) {
|
||||
@ -64,6 +67,33 @@ fun Preference(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Preference(
|
||||
title: String,
|
||||
icon: @Composable (() -> Unit),
|
||||
iconPadding: Boolean = true,
|
||||
summary: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
controls: @Composable (() -> Unit)? = null,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Preference(
|
||||
title = {
|
||||
Text(text = title)
|
||||
},
|
||||
summary = if (summary != null) {
|
||||
{
|
||||
Text(text = summary)
|
||||
}
|
||||
} else null,
|
||||
icon = if (iconPadding) icon else null,
|
||||
onClick = onClick,
|
||||
controls = controls,
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Preference(
|
||||
title: String,
|
||||
|
||||
@ -6,6 +6,8 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
@ -31,6 +33,8 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Build
|
||||
import androidx.compose.material.icons.rounded.Error
|
||||
import androidx.compose.material.icons.rounded.HelpOutline
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.LinkOff
|
||||
import androidx.compose.material.icons.rounded.OpenInNew
|
||||
import androidx.compose.material.icons.rounded.UnfoldMore
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@ -64,7 +68,9 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import de.mm20.launcher2.calendar.CalendarRepository
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.permissions.PermissionGroup
|
||||
import de.mm20.launcher2.permissions.PermissionsManager
|
||||
@ -74,6 +80,7 @@ import de.mm20.launcher2.ui.component.BottomSheetDialog
|
||||
import de.mm20.launcher2.ui.component.LargeMessage
|
||||
import de.mm20.launcher2.ui.component.MissingPermissionBanner
|
||||
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
|
||||
import de.mm20.launcher2.ui.component.preferences.Preference
|
||||
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
|
||||
import de.mm20.launcher2.ui.launcher.widgets.external.ExternalWidget
|
||||
import de.mm20.launcher2.ui.settings.SettingsActivity
|
||||
@ -86,6 +93,8 @@ import de.mm20.launcher2.widgets.WeatherWidget
|
||||
import de.mm20.launcher2.widgets.Widget
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.get
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@ -606,5 +615,87 @@ fun ConfigureNotesWidget(
|
||||
widget: NotesWidget,
|
||||
onWidgetUpdated: (NotesWidget) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val linkFileLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("text/markdown")
|
||||
) {
|
||||
it ?: return@rememberLauncherForActivityResult
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
it,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
if (widget.config.linkedFile != null) {
|
||||
try {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(widget.config.linkedFile),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
onWidgetUpdated(
|
||||
widget.copy(
|
||||
config = widget.config.copy(
|
||||
linkedFile = it.toString(),
|
||||
lastSyncSuccessful = false
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
OutlinedCard {
|
||||
if (widget.config.linkedFile != null) {
|
||||
Preference(
|
||||
icon = { Icon(Icons.Rounded.LinkOff, null) },
|
||||
title = { Text(stringResource(R.string.note_widget_action_unlink_file)) },
|
||||
summary = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.note_widget_linked_file_summary,
|
||||
formatLinkedFileUri(widget.config.linkedFile?.toUri())
|
||||
)
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
try {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(widget.config.linkedFile),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
onWidgetUpdated(widget.copy(config = widget.config.copy(linkedFile = null)))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Preference(
|
||||
title = stringResource(R.string.note_widget_link_file),
|
||||
summary = stringResource(R.string.note_widget_link_file_summary),
|
||||
icon = Icons.Rounded.Link,
|
||||
onClick = {
|
||||
linkFileLauncher.launch(
|
||||
context.getString(
|
||||
R.string.notes_widget_export_filename,
|
||||
ZonedDateTime.now().format(
|
||||
DateTimeFormatter.ISO_INSTANT
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatLinkedFileUri(uri: Uri?): String {
|
||||
if (uri == null) return ""
|
||||
if (uri.scheme == "content" && uri.authority == "com.android.externalstorage.documents") {
|
||||
return uri.lastPathSegment ?: ""
|
||||
}
|
||||
return uri.toString()
|
||||
}
|
||||
@ -133,15 +133,13 @@ fun WidgetItem(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
if (widget !is NotesWidget) {
|
||||
IconButton(onClick = {
|
||||
configure = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Tune,
|
||||
contentDescription = stringResource(R.string.settings)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
configure = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Tune,
|
||||
contentDescription = stringResource(R.string.settings)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onWidgetRemove() }) {
|
||||
Icon(
|
||||
|
||||
@ -1,29 +1,54 @@
|
||||
package de.mm20.launcher2.ui.launcher.widgets.notes
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.CheckCircleOutline
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.LinkOff
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.rounded.SaveAlt
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltipBox
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@ -33,15 +58,20 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.mm20.launcher2.ui.R
|
||||
import de.mm20.launcher2.ui.component.Banner
|
||||
import de.mm20.launcher2.ui.component.BottomSheetDialog
|
||||
import de.mm20.launcher2.ui.component.markdown.MarkdownEditor
|
||||
import de.mm20.launcher2.ui.component.markdown.MarkdownText
|
||||
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
|
||||
import de.mm20.launcher2.widgets.NotesWidget
|
||||
import de.mm20.launcher2.widgets.Widget
|
||||
@ -59,13 +89,20 @@ fun NotesWidget(
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
var showConflictResolveSheet by remember { mutableStateOf(false) }
|
||||
var readWriteErrorSheetText by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
|
||||
val viewModel: NotesWidgetVM =
|
||||
viewModel(key = "notes-widget-${widget.id}", factory = NotesWidgetVM.Factory)
|
||||
|
||||
val isLastWidget by viewModel.isLastNoteWidget.collectAsState(null)
|
||||
|
||||
LaunchedEffect(widget) {
|
||||
viewModel.updateWidget(widget)
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
viewModel.updateWidget(context, widget)
|
||||
}
|
||||
}
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
@ -75,7 +112,40 @@ fun NotesWidget(
|
||||
}
|
||||
)
|
||||
|
||||
val linkFileLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("text/markdown")
|
||||
) {
|
||||
it ?: return@rememberLauncherForActivityResult
|
||||
viewModel.linkFile(context, it)
|
||||
}
|
||||
|
||||
val text by viewModel.noteText
|
||||
if (viewModel.linkedFileConflict.value) {
|
||||
Banner(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = stringResource(R.string.note_widget_conflict),
|
||||
icon = Icons.Rounded.ErrorOutline,
|
||||
primaryAction = {
|
||||
Button(onClick = { showConflictResolveSheet = true }) {
|
||||
Text(stringResource(R.string.note_widget_conflict_action_resolve))
|
||||
}
|
||||
},
|
||||
)
|
||||
if (showConflictResolveSheet) {
|
||||
NoteWidgetConflictResolveSheet(
|
||||
localContent = widget.config.storedText,
|
||||
fileContent = text.text,
|
||||
onResolve = {
|
||||
viewModel.resolveFileContentConflict(context, it)
|
||||
showConflictResolveSheet = false
|
||||
},
|
||||
onDismissRequest = {
|
||||
showConflictResolveSheet = false
|
||||
}
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@ -85,7 +155,9 @@ fun NotesWidget(
|
||||
) {
|
||||
MarkdownEditor(
|
||||
value = text,
|
||||
onValueChange = { viewModel.setText(it) },
|
||||
onValueChange = { viewModel.setText(context, it) },
|
||||
focus = focused,
|
||||
onFocusChange = { focused = it },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(16.dp),
|
||||
@ -93,29 +165,99 @@ fun NotesWidget(
|
||||
Text(
|
||||
stringResource(R.string.notes_widget_placeholder),
|
||||
)
|
||||
|
||||
}
|
||||
)
|
||||
AnimatedVisibility(isLastWidget == false && text.text.isBlank()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.dismissNote()
|
||||
},
|
||||
modifier = Modifier.padding(8.dp)
|
||||
AnimatedVisibility(text.text.isBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Delete, null)
|
||||
PlainTooltipBox(tooltip = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (widget.config.linkedFile == null) R.string.note_widget_link_file
|
||||
else R.string.note_widget_action_unlink_file
|
||||
)
|
||||
)
|
||||
}) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (widget.config.linkedFile == null) {
|
||||
linkFileLauncher.launch(
|
||||
getDefaultNoteFileName(context)
|
||||
)
|
||||
} else {
|
||||
viewModel.unlinkFile(context)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.tooltipTrigger()
|
||||
) {
|
||||
Icon(
|
||||
if (widget.config.linkedFile == null) Icons.Rounded.Link
|
||||
else Icons.Rounded.LinkOff,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isLastWidget == false) {
|
||||
PlainTooltipBox(tooltip = { Text(stringResource(R.string.notes_widget_action_dismiss)) }) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.dismissNote()
|
||||
},
|
||||
modifier = Modifier
|
||||
.tooltipTrigger()
|
||||
) {
|
||||
Icon(Icons.Rounded.Delete, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(text.text.isNotBlank()) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (viewModel.linkedFileSavingState.value == LinkedFileSavingState.Error) {
|
||||
TextButton(
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
onClick = {
|
||||
readWriteErrorSheetText =
|
||||
context.getString(R.string.note_widget_file_write_error_description)
|
||||
}) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(end = ButtonDefaults.IconSpacing)
|
||||
.size(ButtonDefaults.IconSize),
|
||||
imageVector = Icons.Rounded.ErrorOutline,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(stringResource(R.string.note_widget_file_write_error))
|
||||
}
|
||||
} else if (viewModel.linkedFileReadError.value) {
|
||||
TextButton(
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
onClick = {
|
||||
readWriteErrorSheetText =
|
||||
context.getString(R.string.note_widget_file_read_error_description)
|
||||
}) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(end = ButtonDefaults.IconSpacing)
|
||||
.size(ButtonDefaults.IconSize),
|
||||
imageVector = Icons.Rounded.ErrorOutline,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(stringResource(R.string.note_widget_file_read_error))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Rounded.MoreVert, null)
|
||||
@ -129,28 +271,11 @@ fun NotesWidget(
|
||||
onClick = {
|
||||
val newWidget = NotesWidget(
|
||||
id = UUID.randomUUID(),
|
||||
widget.config.copy(storedText = "")
|
||||
)
|
||||
onWidgetAdd(newWidget, 1)
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.notes_widget_action_save)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.SaveAlt, null)
|
||||
},
|
||||
onClick = {
|
||||
val fileName = context.getString(
|
||||
R.string.notes_widget_export_filename,
|
||||
ZonedDateTime.now().format(
|
||||
DateTimeFormatter.ISO_INSTANT
|
||||
)
|
||||
)
|
||||
exportLauncher.launch("$fileName.md")
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_share)) },
|
||||
leadingIcon = {
|
||||
@ -165,38 +290,71 @@ fun NotesWidget(
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.notes_widget_action_save)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.SaveAlt, null)
|
||||
},
|
||||
onClick = {
|
||||
val fileName = getDefaultNoteFileName(context)
|
||||
exportLauncher.launch(fileName)
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
if (widget.config.linkedFile == null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.note_widget_link_file)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.Link, null)
|
||||
},
|
||||
onClick = {
|
||||
linkFileLauncher.launch(getDefaultNoteFileName(context))
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.note_widget_action_unlink_file)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.LinkOff, null)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.unlinkFile(context)
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.notes_widget_action_dismiss)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.Delete, null)
|
||||
},
|
||||
onClick = {
|
||||
if (isLastWidget == false) {
|
||||
viewModel.dismissNote()
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.notes_widget_dismissed),
|
||||
actionLabel = context.getString(R.string.action_undo),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
onWidgetAdd(widget, 0)
|
||||
val wasLast = isLastWidget != false
|
||||
viewModel.dismissWidget(widget)
|
||||
val newWidget =
|
||||
if (wasLast) {
|
||||
NotesWidget(UUID.randomUUID()).also {
|
||||
onWidgetAdd(it, 0)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
val content = text
|
||||
viewModel.setText(TextFieldValue(""))
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.notes_widget_dismissed),
|
||||
actionLabel = context.getString(R.string.action_undo),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
viewModel.setText(content)
|
||||
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.notes_widget_dismissed),
|
||||
actionLabel = context.getString(R.string.action_undo),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
onWidgetAdd(widget, 0)
|
||||
newWidget?.let {
|
||||
viewModel.dismissWidget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showMenu = false
|
||||
},
|
||||
)
|
||||
@ -205,4 +363,250 @@ fun NotesWidget(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readWriteErrorSheetText != null) {
|
||||
NoteReadWriteErrorSheet(
|
||||
message = readWriteErrorSheetText!!,
|
||||
onDismiss = { readWriteErrorSheetText = null },
|
||||
onRelink = {
|
||||
linkFileLauncher.launch(
|
||||
context.getString(
|
||||
R.string.notes_widget_export_filename,
|
||||
ZonedDateTime.now().format(
|
||||
DateTimeFormatter.ISO_INSTANT
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
onUnlink = {
|
||||
viewModel.unlinkFile(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NoteWidgetConflictResolveSheet(
|
||||
localContent: String,
|
||||
fileContent: String,
|
||||
onResolve: (LinkedFileConflictStrategy) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
var selectedStrategy by remember { mutableStateOf<LinkedFileConflictStrategy?>(null) }
|
||||
BottomSheetDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onResolve(selectedStrategy ?: return@Button) },
|
||||
enabled = selectedStrategy != null,
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.CheckCircleOutline,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = ButtonDefaults.IconSpacing)
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Text(stringResource(R.string.note_widget_conflict_keep_selected))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { onResolve(LinkedFileConflictStrategy.Unlink) },
|
||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.LinkOff,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = ButtonDefaults.IconSpacing)
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Text(stringResource(R.string.note_widget_action_unlink_file))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(it),
|
||||
) {
|
||||
|
||||
Text(
|
||||
stringResource(R.string.note_widget_conflict_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.note_widget_conflict_local_version),
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
SelectableNoteContent(
|
||||
content = localContent,
|
||||
selected = selectedStrategy == LinkedFileConflictStrategy.KeepLocal,
|
||||
onSelect = { selectedStrategy = LinkedFileConflictStrategy.KeepLocal },
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.note_widget_conflict_file_version),
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
SelectableNoteContent(
|
||||
content = fileContent,
|
||||
selected = selectedStrategy == LinkedFileConflictStrategy.KeepFile,
|
||||
onSelect = { selectedStrategy = LinkedFileConflictStrategy.KeepFile },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectableNoteContent(
|
||||
content: String,
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val color =
|
||||
if (selected) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
border = BorderStroke(
|
||||
if (selected) 2.dp else 1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant
|
||||
),
|
||||
color = color,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = onSelect,
|
||||
onLongClick = { expanded = !expanded },
|
||||
)
|
||||
.animateContentSize() then if (expanded) Modifier.heightIn(min = 100.dp) else Modifier.height(100.dp),
|
||||
) {
|
||||
MarkdownText(
|
||||
modifier = Modifier
|
||||
.wrapContentHeight(align = Alignment.Top, unbounded = true)
|
||||
.padding(16.dp),
|
||||
text = content, onTextChange = {}
|
||||
)
|
||||
if (!expanded) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to color.copy(alpha = 0f),
|
||||
1f to color.copy(alpha = 0.8f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
onClick = onSelect
|
||||
) {
|
||||
Icon(
|
||||
if (selected)
|
||||
Icons.Rounded.CheckCircle
|
||||
else
|
||||
Icons.Rounded.RadioButtonUnchecked,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
onClick = { expanded = !expanded }
|
||||
) {
|
||||
Icon(
|
||||
if (expanded)
|
||||
Icons.Rounded.ExpandLess
|
||||
else
|
||||
Icons.Rounded.ExpandMore,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NoteReadWriteErrorSheet(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
onRelink: () -> Unit,
|
||||
onUnlink: () -> Unit,
|
||||
) {
|
||||
BottomSheetDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 8.dp, bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onRelink()
|
||||
onDismiss()
|
||||
}
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Rounded.Link, null)
|
||||
Text(
|
||||
stringResource(R.string.note_widget_action_relink_file),
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onUnlink()
|
||||
onDismiss()
|
||||
}
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Rounded.LinkOff, null)
|
||||
Text(
|
||||
stringResource(R.string.note_widget_action_unlink_file),
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultNoteFileName(context: Context): String {
|
||||
return context.getString(
|
||||
R.string.notes_widget_export_filename,
|
||||
ZonedDateTime.now().format(
|
||||
DateTimeFormatter.ISO_INSTANT
|
||||
)
|
||||
) + ".md"
|
||||
}
|
||||
@ -1,16 +1,18 @@
|
||||
package de.mm20.launcher2.ui.launcher.widgets.notes
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.services.widgets.WidgetsService
|
||||
import de.mm20.launcher2.widgets.NotesWidget
|
||||
import de.mm20.launcher2.widgets.Widget
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@ -19,6 +21,8 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
@ -29,24 +33,84 @@ class NotesWidgetVM(
|
||||
|
||||
val noteText = mutableStateOf(TextFieldValue(widget.value?.config?.storedText ?: ""))
|
||||
|
||||
val linkedFileConflict = mutableStateOf(false)
|
||||
val linkedFileSavingState = mutableStateOf(LinkedFileSavingState.Saved)
|
||||
val linkedFileReadError = mutableStateOf(false)
|
||||
|
||||
val isLastNoteWidget = widgetsService.countWidgets(NotesWidget.Type).map {
|
||||
it == 1
|
||||
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
|
||||
|
||||
fun updateWidget(widget: NotesWidget) {
|
||||
fun updateWidget(context: Context, widget: NotesWidget) {
|
||||
val oldId = this.widget.value?.id
|
||||
val oldFileUri = this.widget.value?.config?.linkedFile
|
||||
this.widget.value = widget
|
||||
if (widget.id != oldId) noteText.value = TextFieldValue(widget.config.storedText)
|
||||
if (widget.id != oldId || oldFileUri != widget.config.linkedFile) {
|
||||
val file = widget.config.linkedFile
|
||||
linkedFileConflict.value = false
|
||||
linkedFileReadError.value = false
|
||||
linkedFileSavingState.value = LinkedFileSavingState.Saved
|
||||
if (file != null) {
|
||||
val uri = Uri.parse(file)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri).use {
|
||||
val text = it?.bufferedReader()?.readText()
|
||||
if (text != widget.config.storedText) {
|
||||
when {
|
||||
widget.config.lastSyncSuccessful -> {
|
||||
setText(context, TextFieldValue(text ?: ""))
|
||||
}
|
||||
text?.isNotBlank() == true && widget.config.storedText.isNotBlank() -> {
|
||||
if (!widget.config.lastSyncSuccessful) {
|
||||
linkedFileConflict.value = true
|
||||
noteText.value = TextFieldValue(text)
|
||||
} else {
|
||||
setText(context, TextFieldValue(text))
|
||||
}
|
||||
}
|
||||
text.isNullOrBlank() -> {
|
||||
setText(context, TextFieldValue(widget.config.storedText))
|
||||
}
|
||||
else -> {
|
||||
setText(context, TextFieldValue(text))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
noteText.value = TextFieldValue(widget.config.storedText)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Catch-all because for some reason the content resolver can throw all sorts of exceptions
|
||||
CrashReporter.logException(e)
|
||||
noteText.value = TextFieldValue(widget.config.storedText)
|
||||
linkedFileReadError.value = true
|
||||
widgetsService.updateWidget(
|
||||
widget.copy(
|
||||
config = widget.config.copy(
|
||||
lastSyncSuccessful = false
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
noteText.value = TextFieldValue(widget.config.storedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var updateJob: Job? = null
|
||||
fun setText(text: TextFieldValue) {
|
||||
fun setText(context: Context, text: TextFieldValue) {
|
||||
noteText.value = text
|
||||
updateJob?.cancel()
|
||||
val widget = widget.value ?: return
|
||||
updateJob = viewModelScope.launch {
|
||||
delay(1000)
|
||||
widgetsService.updateWidget(widget.copy(config = widget.config.copy(storedText = text.text)))
|
||||
val success = if (widget.config.linkedFile != null) {
|
||||
writeContentToFile(context, Uri.parse(widget.config.linkedFile), text.text)
|
||||
} else false
|
||||
widgetsService.updateWidget(widget.copy(config = widget.config.copy(storedText = text.text, lastSyncSuccessful = success)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,12 +128,143 @@ class NotesWidgetVM(
|
||||
widgetsService.removeWidget(widget.value ?: return)
|
||||
}
|
||||
|
||||
private var writeSemaphore = Semaphore(1)
|
||||
private suspend fun writeContentToFile(context: Context, uri: Uri, text: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
writeSemaphore.acquire()
|
||||
try {
|
||||
val outputStream = context.contentResolver.openOutputStream(uri, "wt")
|
||||
outputStream?.use {
|
||||
it.bufferedWriter().use {
|
||||
it.write(text)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
linkedFileSavingState.value = LinkedFileSavingState.Error
|
||||
CrashReporter.logException(e)
|
||||
return@withContext false
|
||||
}
|
||||
writeSemaphore.release()
|
||||
return@withContext true
|
||||
}
|
||||
}
|
||||
|
||||
companion object: KoinComponent {
|
||||
fun resolveFileContentConflict(context: Context, strategy: LinkedFileConflictStrategy) {
|
||||
val widget = widget.value ?: return
|
||||
val linkedFile = widget.config.linkedFile ?: return
|
||||
when (strategy) {
|
||||
LinkedFileConflictStrategy.KeepLocal -> {
|
||||
val text = widget.config.storedText
|
||||
viewModelScope.launch {
|
||||
val success = writeContentToFile(context, Uri.parse(linkedFile), text)
|
||||
noteText.value = TextFieldValue(text)
|
||||
if (success) {
|
||||
widgetsService.updateWidget(
|
||||
widget.copy(
|
||||
config = widget.config.copy(
|
||||
lastSyncSuccessful = true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LinkedFileConflictStrategy.KeepFile -> {
|
||||
val text = noteText.value.text
|
||||
widgetsService.updateWidget(
|
||||
widget.copy(
|
||||
config = widget.config.copy(
|
||||
lastSyncSuccessful = true,
|
||||
storedText = text,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LinkedFileConflictStrategy.Unlink -> {
|
||||
noteText.value = TextFieldValue(widget.config.storedText)
|
||||
unlinkFile(context)
|
||||
}
|
||||
}
|
||||
linkedFileConflict.value = false
|
||||
}
|
||||
|
||||
fun unlinkFile(context: Context) {
|
||||
val widget = widget.value ?: return
|
||||
widgetsService.updateWidget(
|
||||
widget.copy(
|
||||
config = widget.config.copy(
|
||||
linkedFile = null,
|
||||
lastSyncSuccessful = false
|
||||
)
|
||||
)
|
||||
)
|
||||
linkedFileSavingState.value = LinkedFileSavingState.Saved
|
||||
linkedFileReadError.value = false
|
||||
linkedFileConflict.value = false
|
||||
try {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(widget.config.linkedFile),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissWidget(widget: Widget) {
|
||||
widgetsService.removeWidget(widget)
|
||||
}
|
||||
|
||||
fun linkFile(context: Context, uri: Uri) {
|
||||
val widget = widget.value ?: return
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
if (widget.config.linkedFile != null) {
|
||||
try {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(widget.config.linkedFile),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
widgetsService.updateWidget(
|
||||
widget.copy(
|
||||
config = widget.config.copy(
|
||||
linkedFile = uri.toString(),
|
||||
lastSyncSuccessful = false
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
CrashReporter.logException(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object : KoinComponent {
|
||||
val Factory = viewModelFactory {
|
||||
initializer {
|
||||
NotesWidgetVM(get())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class LinkedFileConflictStrategy {
|
||||
KeepLocal,
|
||||
KeepFile,
|
||||
Unlink,
|
||||
}
|
||||
|
||||
enum class LinkedFileSavingState {
|
||||
Saved,
|
||||
Saving,
|
||||
Error,
|
||||
}
|
||||
@ -803,4 +803,19 @@
|
||||
<string name="widget_config_calendar_no_calendars">No calendars found</string>
|
||||
<string name="app_widget_loading_failed">App widget failed to load.</string>
|
||||
<string name="widget_config_music_integration_settings">Media control integration settings</string>
|
||||
<string name="note_widget_link_file">Link to file</string>
|
||||
<string name="note_widget_link_file_summary">Sync this content of this widget with an external file</string>
|
||||
<string name="note_widget_linked_file_summary">Linked to %1$s</string>
|
||||
<string name="note_widget_action_unlink_file">Unlink</string>
|
||||
<string name="note_widget_action_relink_file">Relink</string>
|
||||
<string name="note_widget_conflict">Conflict</string>
|
||||
<string name="note_widget_conflict_action_resolve">Resolve</string>
|
||||
<string name="note_widget_conflict_keep_selected">Keep selected</string>
|
||||
<string name="note_widget_conflict_description">The linked file is not empty and its content does not match the last saved version of this note. Which version do you want to keep?</string>
|
||||
<string name="note_widget_conflict_local_version">Last saved version:</string>
|
||||
<string name="note_widget_conflict_file_version">Current file content:</string>
|
||||
<string name="note_widget_file_read_error">Error reading note</string>
|
||||
<string name="note_widget_file_read_error_description">The linked file could not be read. Possibly, it has been moved or deleted. A copy has been restored from the launcher\'s internal storage. If you edit the note, the linked file will possibly be overwritten.</string>
|
||||
<string name="note_widget_file_write_error">Error saving note</string>
|
||||
<string name="note_widget_file_write_error_description">The note could not be written to the linked file. Possibly, it has been moved or deleted. A copy has been saved to the launcher\'s internal storage.</string>
|
||||
</resources>
|
||||
@ -8,8 +8,16 @@ import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class NotesWidgetConfig(
|
||||
/**
|
||||
* Text content of the widget. If the widget is linked to a file, this is the last saved content.
|
||||
*/
|
||||
val storedText: String = "",
|
||||
val quickActions: Boolean = true,
|
||||
val linkedFile: String? = null,
|
||||
/**
|
||||
* Indicates whether the last read/write operation on the linked file was successful.
|
||||
* If false, a conflict resolver will be shown if the note content differs from the file content.
|
||||
*/
|
||||
val lastSyncSuccessful: Boolean = false,
|
||||
)
|
||||
|
||||
data class NotesWidget(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user