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.FractionalThreshold
import androidx.compose.material.SwipeableState import androidx.compose.material.SwipeableState
import androidx.compose.material.swipeable import androidx.compose.material.swipeable
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.LocalAbsoluteTonalElevation import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -65,8 +66,8 @@ import kotlin.math.roundToInt
@Composable @Composable
fun BottomSheetDialog( fun BottomSheetDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
title: @Composable () -> Unit, title: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}, actions: (@Composable RowScope.() -> Unit)? = null,
confirmButton: @Composable (() -> Unit)? = null, confirmButton: @Composable (() -> Unit)? = null,
dismissButton: @Composable (() -> Unit)? = null, dismissButton: @Composable (() -> Unit)? = null,
dismissible: () -> Boolean = { true }, dismissible: () -> Boolean = { true },
@ -227,13 +228,21 @@ fun BottomSheetDialog(
shadowElevation = 16.dp, shadowElevation = 16.dp,
) { ) {
Column { Column {
CenterAlignedTopAppBar( if (title != null || actions != null) {
title = title, CenterAlignedTopAppBar(
actions = actions, title = title ?: { BottomSheetDefaults.DragHandle() },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( actions = actions ?: {},
containerColor = Color.Transparent, colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
), containerColor = Color.Transparent,
) ),
)
} else {
Box(
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
BottomSheetDefaults.DragHandle()
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -36,24 +36,25 @@ import org.intellij.markdown.parser.MarkdownParser
fun MarkdownEditor( fun MarkdownEditor(
value: TextFieldValue, value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit, onValueChange: (TextFieldValue) -> Unit,
focus: Boolean,
onFocusChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeholder: (@Composable () -> Unit)? = null placeholder: (@Composable () -> Unit)? = null
) { ) {
val typography = MaterialTheme.typography val typography = MaterialTheme.typography
val delimiterColor = MaterialTheme.colorScheme.secondary val delimiterColor = MaterialTheme.colorScheme.secondary
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var focused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
BackHandler( BackHandler(
enabled = focused enabled = focus
) { ) {
focused = false onFocusChange(false)
} }
if (focused) { if (focus) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
@ -119,7 +120,7 @@ fun MarkdownEditor(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
) { ) {
focused = true onFocusChange(true)
}, },
) { ) {
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
@ -137,7 +138,7 @@ fun MarkdownEditor(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
) { ) {
focused = true onFocusChange(true)
}, },
onTextChange = { onValueChange(TextFieldValue(it)) }, onTextChange = { onValueChange(TextFieldValue(it)) },
) )

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -14,10 +15,9 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun Preference( fun Preference(
title: String, title: @Composable (() -> Unit),
icon: @Composable (() -> Unit), summary: @Composable (() -> Unit)? = null,
iconPadding: Boolean = true, icon: @Composable (() -> Unit)? = null,
summary: String? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
controls: @Composable (() -> Unit)? = null, controls: @Composable (() -> Unit)? = null,
enabled: Boolean = true enabled: Boolean = true
@ -30,7 +30,7 @@ fun Preference(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.alpha(if (enabled) 1f else 0.38f), .alpha(if (enabled) 1f else 0.38f),
) { ) {
if (iconPadding) { if (icon != null) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(56.dp) .width(56.dp)
@ -43,15 +43,18 @@ fun Preference(
Box(modifier = Modifier.size(0.dp)) Box(modifier = Modifier.size(0.dp))
} }
Column( 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) { if (summary != null) {
Text( Spacer(modifier = Modifier.height(2.dp))
text = summary, ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) {
style = MaterialTheme.typography.bodyMedium, summary()
modifier = Modifier.padding(top = 2.dp) }
)
} }
} }
if (controls != null) { 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 @Composable
fun Preference( fun Preference(
title: String, title: String,

View File

@ -6,6 +6,8 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo import android.appwidget.AppWidgetProviderInfo
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent 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.Build
import androidx.compose.material.icons.rounded.Error import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.HelpOutline 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.OpenInNew
import androidx.compose.material.icons.rounded.UnfoldMore import androidx.compose.material.icons.rounded.UnfoldMore
import androidx.compose.material3.ButtonDefaults 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.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import de.mm20.launcher2.calendar.CalendarRepository import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager 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.LargeMessage
import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference 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.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.launcher.widgets.external.ExternalWidget import de.mm20.launcher2.ui.launcher.widgets.external.ExternalWidget
import de.mm20.launcher2.ui.settings.SettingsActivity import de.mm20.launcher2.ui.settings.SettingsActivity
@ -86,6 +93,8 @@ import de.mm20.launcher2.widgets.WeatherWidget
import de.mm20.launcher2.widgets.Widget import de.mm20.launcher2.widgets.Widget
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.get import org.koin.androidx.compose.get
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -606,5 +615,87 @@ fun ConfigureNotesWidget(
widget: NotesWidget, widget: NotesWidget,
onWidgetUpdated: (NotesWidget) -> Unit 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, overflow = TextOverflow.Ellipsis,
maxLines = 1 maxLines = 1
) )
if (widget !is NotesWidget) { IconButton(onClick = {
IconButton(onClick = { configure = true
configure = true }) {
}) { Icon(
Icon( imageVector = Icons.Rounded.Tune,
imageVector = Icons.Rounded.Tune, contentDescription = stringResource(R.string.settings)
contentDescription = stringResource(R.string.settings) )
)
}
} }
IconButton(onClick = { onWidgetRemove() }) { IconButton(onClick = { onWidgetRemove() }) {
Icon( Icon(

View File

@ -1,29 +1,54 @@
package de.mm20.launcher2.ui.launcher.widgets.notes package de.mm20.launcher2.ui.launcher.widgets.notes
import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.rounded.Add 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.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.MoreVert
import androidx.compose.material.icons.rounded.RadioButtonUnchecked
import androidx.compose.material.icons.rounded.SaveAlt import androidx.compose.material.icons.rounded.SaveAlt
import androidx.compose.material.icons.rounded.Share 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltipBox
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -33,15 +58,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R 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.MarkdownEditor
import de.mm20.launcher2.ui.component.markdown.MarkdownText
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.widgets.NotesWidget import de.mm20.launcher2.widgets.NotesWidget
import de.mm20.launcher2.widgets.Widget import de.mm20.launcher2.widgets.Widget
@ -59,13 +89,20 @@ fun NotesWidget(
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val lifecycleOwner = LocalLifecycleOwner.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 = val viewModel: NotesWidgetVM =
viewModel(key = "notes-widget-${widget.id}", factory = NotesWidgetVM.Factory) viewModel(key = "notes-widget-${widget.id}", factory = NotesWidgetVM.Factory)
val isLastWidget by viewModel.isLastNoteWidget.collectAsState(null) val isLastWidget by viewModel.isLastNoteWidget.collectAsState(null)
LaunchedEffect(widget) { LaunchedEffect(widget) {
viewModel.updateWidget(widget) lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.updateWidget(context, widget)
}
} }
val exportLauncher = rememberLauncherForActivityResult( 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 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 { Column {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -85,7 +155,9 @@ fun NotesWidget(
) { ) {
MarkdownEditor( MarkdownEditor(
value = text, value = text,
onValueChange = { viewModel.setText(it) }, onValueChange = { viewModel.setText(context, it) },
focus = focused,
onFocusChange = { focused = it },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(16.dp), .padding(16.dp),
@ -93,29 +165,99 @@ fun NotesWidget(
Text( Text(
stringResource(R.string.notes_widget_placeholder), stringResource(R.string.notes_widget_placeholder),
) )
} }
) )
AnimatedVisibility(isLastWidget == false && text.text.isBlank()) { AnimatedVisibility(text.text.isBlank()) {
IconButton( Row(
onClick = { modifier = Modifier.padding(8.dp),
viewModel.dismissNote()
},
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()) { AnimatedVisibility(text.text.isNotBlank()) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
Box( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp), .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 { Box {
IconButton(onClick = { showMenu = true }) { IconButton(onClick = { showMenu = true }) {
Icon(Icons.Rounded.MoreVert, null) Icon(Icons.Rounded.MoreVert, null)
@ -129,28 +271,11 @@ fun NotesWidget(
onClick = { onClick = {
val newWidget = NotesWidget( val newWidget = NotesWidget(
id = UUID.randomUUID(), id = UUID.randomUUID(),
widget.config.copy(storedText = "")
) )
onWidgetAdd(newWidget, 1) onWidgetAdd(newWidget, 1)
showMenu = false 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( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_share)) }, text = { Text(stringResource(R.string.menu_share)) },
leadingIcon = { leadingIcon = {
@ -165,38 +290,71 @@ fun NotesWidget(
showMenu = false 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( DropdownMenuItem(
text = { Text(stringResource(R.string.notes_widget_action_dismiss)) }, text = { Text(stringResource(R.string.notes_widget_action_dismiss)) },
leadingIcon = { leadingIcon = {
Icon(Icons.Rounded.Delete, null) Icon(Icons.Rounded.Delete, null)
}, },
onClick = { onClick = {
if (isLastWidget == false) { val wasLast = isLastWidget != false
viewModel.dismissNote() viewModel.dismissWidget(widget)
lifecycleOwner.lifecycleScope.launch { val newWidget =
val result = snackbarHostState.showSnackbar( if (wasLast) {
message = context.getString(R.string.notes_widget_dismissed), NotesWidget(UUID.randomUUID()).also {
actionLabel = context.getString(R.string.action_undo), onWidgetAdd(it, 0)
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
onWidgetAdd(widget, 0)
} }
} else {
null
} }
} else {
val content = text lifecycleOwner.lifecycleScope.launch {
viewModel.setText(TextFieldValue("")) val result = snackbarHostState.showSnackbar(
lifecycleOwner.lifecycleScope.launch { message = context.getString(R.string.notes_widget_dismissed),
val result = snackbarHostState.showSnackbar( actionLabel = context.getString(R.string.action_undo),
message = context.getString(R.string.notes_widget_dismissed), duration = SnackbarDuration.Short,
actionLabel = context.getString(R.string.action_undo), )
duration = SnackbarDuration.Short, if (result == SnackbarResult.ActionPerformed) {
) onWidgetAdd(widget, 0)
if (result == SnackbarResult.ActionPerformed) { newWidget?.let {
viewModel.setText(content) viewModel.dismissWidget(it)
} }
} }
} }
showMenu = false 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 package de.mm20.launcher2.ui.launcher.widgets.notes
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.services.widgets.WidgetsService import de.mm20.launcher2.services.widgets.WidgetsService
import de.mm20.launcher2.widgets.NotesWidget import de.mm20.launcher2.widgets.NotesWidget
import de.mm20.launcher2.widgets.Widget
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -19,6 +21,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
@ -29,24 +33,84 @@ class NotesWidgetVM(
val noteText = mutableStateOf(TextFieldValue(widget.value?.config?.storedText ?: "")) 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 { val isLastNoteWidget = widgetsService.countWidgets(NotesWidget.Type).map {
it == 1 it == 1
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) }.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
fun updateWidget(widget: NotesWidget) { fun updateWidget(context: Context, widget: NotesWidget) {
val oldId = this.widget.value?.id val oldId = this.widget.value?.id
val oldFileUri = this.widget.value?.config?.linkedFile
this.widget.value = widget 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 private var updateJob: Job? = null
fun setText(text: TextFieldValue) { fun setText(context: Context, text: TextFieldValue) {
noteText.value = text noteText.value = text
updateJob?.cancel() updateJob?.cancel()
val widget = widget.value ?: return val widget = widget.value ?: return
updateJob = viewModelScope.launch { updateJob = viewModelScope.launch {
delay(1000) 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,8 +128,127 @@ class NotesWidgetVM(
widgetsService.removeWidget(widget.value ?: return) 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 { val Factory = viewModelFactory {
initializer { initializer {
NotesWidgetVM(get()) NotesWidgetVM(get())
@ -73,3 +256,15 @@ class NotesWidgetVM(
} }
} }
} }
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="widget_config_calendar_no_calendars">No calendars found</string>
<string name="app_widget_loading_failed">App widget failed to load.</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="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> </resources>

View File

@ -8,8 +8,16 @@ import java.util.UUID
@Serializable @Serializable
data class NotesWidgetConfig( 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 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( data class NotesWidget(