Add note widget file sync

This commit is contained in:
MM20 2023-06-12 15:18:12 +02:00
parent 0280de4bbc
commit bbe96ed09a
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
9 changed files with 845 additions and 94 deletions

View File

@ -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()

View File

@ -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)) },
)

View File

@ -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,

View File

@ -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()
}

View File

@ -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(

View File

@ -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"
}

View File

@ -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,
}

View File

@ -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>

View File

@ -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(