From bbe96ed09a852a2c15a6a137d5377fd0f34dbada Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Mon, 12 Jun 2023 15:18:12 +0200 Subject: [PATCH] Add note widget file sync --- .../ui/component/BottomSheetDialog.kt | 27 +- .../ui/component/markdown/MarkdownEditor.kt | 13 +- .../ui/component/preferences/Preference.kt | 54 +- .../launcher/sheets/ConfigureWidgetSheet.kt | 91 ++++ .../ui/launcher/widgets/WidgetItem.kt | 16 +- .../ui/launcher/widgets/notes/NotesWidget.kt | 506 ++++++++++++++++-- .../launcher/widgets/notes/NotesWidgetVM.kt | 207 ++++++- core/i18n/src/main/res/values/strings.xml | 15 + .../de/mm20/launcher2/widgets/NotesWidget.kt | 10 +- 9 files changed, 845 insertions(+), 94 deletions(-) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt index 94fca0f5..70821acd 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt @@ -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() diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt index 5c19b8f7..5f3b800e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt @@ -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)) }, ) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt index f985349a..bfe38f0b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt @@ -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, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt index f78c1ce2..48cf55bf 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt @@ -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() } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt index 6eaadf50..47f7b7c1 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt @@ -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( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt index 76404b59..16845179 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt @@ -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(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(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" } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt index 98e81734..9fdab2e0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt @@ -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, } \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index dc34dc44..27717e3e 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -803,4 +803,19 @@ No calendars found App widget failed to load. Media control integration settings + Link to file + Sync this content of this widget with an external file + Linked to %1$s + Unlink + Relink + Conflict + Resolve + Keep selected + 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? + Last saved version: + Current file content: + Error reading note + 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. + Error saving note + 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. \ No newline at end of file diff --git a/data/widgets/src/main/java/de/mm20/launcher2/widgets/NotesWidget.kt b/data/widgets/src/main/java/de/mm20/launcher2/widgets/NotesWidget.kt index 87e2ee02..4add7659 100644 --- a/data/widgets/src/main/java/de/mm20/launcher2/widgets/NotesWidget.kt +++ b/data/widgets/src/main/java/de/mm20/launcher2/widgets/NotesWidget.kt @@ -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(