Add note widget file sync
This commit is contained in:
parent
0280de4bbc
commit
bbe96ed09a
@ -29,6 +29,7 @@ import androidx.compose.material.FixedThreshold
|
|||||||
import androidx.compose.material.FractionalThreshold
|
import androidx.compose.material.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()
|
||||||
|
|||||||
@ -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)) },
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user