From 78f54645b380167ed3f9741bef308d0d39759d38 Mon Sep 17 00:00:00 2001 From: Daniel Yrovas Date: Fri, 26 Apr 2024 16:27:10 +1000 Subject: [PATCH] toggle expand title & description --- README.md | 10 ++- .../java/org/yrovas/linklater/AppComponent.kt | 2 +- .../org/yrovas/linklater/data/Bookmark.kt | 20 +++++ .../org/yrovas/linklater/ui/common/Icon.kt | 40 ++++++--- .../yrovas/linklater/ui/common/KeyboardRow.kt | 14 ++- .../yrovas/linklater/ui/common/RefreshIcon.kt | 74 ++++++++++------ .../linklater/ui/common/TextPreference.kt | 34 ++++++-- .../linklater/ui/component/BookmarkRow.kt | 85 +++++++++++++------ .../linklater/ui/component/DestinationHost.kt | 2 +- .../yrovas/linklater/ui/screens/HomeScreen.kt | 5 +- gradle/libs.versions.toml | 4 +- 11 files changed, 203 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index e2b49b3..40939ea 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,19 @@ An unofficial Android client for [LinkDing](https://github.com/sissbruecker/link ## Features - Save bookmarks through the Android share menu. +- Easily add tags to bookmarks. - View recent bookmarks. -## Install +## Installation 1. Use [Obtainium](https://github.com/ImranR98/Obtainium) to install and update from GitHub, or 2. Download the latest apk from the [releases page](https://github.com/danielyrovas/linklater/releases/latest). ## TODO: -- Provide typical CRUD operations on bookmarks -- Simplify selecting/searching for tags when saving bookmarks +- Provide management operations on bookmarks: edit/delete. +- Make tag predictions smarter. +- Tests. +- Use Fastlane to release on the Play store. +- F-Droid release. ## Free Software diff --git a/app/src/main/java/org/yrovas/linklater/AppComponent.kt b/app/src/main/java/org/yrovas/linklater/AppComponent.kt index ca10ef8..f241461 100644 --- a/app/src/main/java/org/yrovas/linklater/AppComponent.kt +++ b/app/src/main/java/org/yrovas/linklater/AppComponent.kt @@ -35,7 +35,7 @@ import org.yrovas.linklater.domain.BookmarkAPI import org.yrovas.linklater.domain.BookmarkDataSource import org.yrovas.linklater.domain.TagDataSource import org.yrovas.linklater.ui.activity.AppActivity -import org.yrovas.linklater.ui.common.DestinationHost +import org.yrovas.linklater.ui.component.DestinationHost const val TAG = "DEBUG/create" val Context.dataStore: DataStore by preferencesDataStore(name = "preferences") diff --git a/app/src/main/java/org/yrovas/linklater/data/Bookmark.kt b/app/src/main/java/org/yrovas/linklater/data/Bookmark.kt index dfe066c..2116e4b 100644 --- a/app/src/main/java/org/yrovas/linklater/data/Bookmark.kt +++ b/app/src/main/java/org/yrovas/linklater/data/Bookmark.kt @@ -24,6 +24,26 @@ data class Bookmark( @SerialName("tag_names") val tags: List = emptyList(), ) +fun Bookmark.showTitleOrElse(value: String): String { + return if (!title.isNullOrBlank()) { + title + } else if (!website_title.isNullOrBlank()) { + website_title + } else { + value + } +} + +fun Bookmark.showDescriptionOrElse(value: String): String { + return if (!description.isNullOrBlank()) { + description + } else if (!website_description.isNullOrBlank()) { + website_description + } else { + value + } +} + fun GetBookmarksWithTags.toBookmark() = Bookmark( id = id, url = url, diff --git a/app/src/main/java/org/yrovas/linklater/ui/common/Icon.kt b/app/src/main/java/org/yrovas/linklater/ui/common/Icon.kt index 3b4bc28..eefb243 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/common/Icon.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/common/Icon.kt @@ -7,35 +7,55 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @Composable -fun Icon(painter: Painter, tint: Color, modifier: Modifier = Modifier) { +fun Icon( + painter: Painter, + tint: Color, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { androidx.compose.material3.Icon( modifier = modifier, painter = painter, - tint = tint, - contentDescription = null + tint = tint, contentDescription = contentDescription ) } @Composable -fun Icon(painter: Painter, modifier: Modifier = Modifier) { +fun Icon( + painter: Painter, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { androidx.compose.material3.Icon( - modifier = modifier, painter = painter, contentDescription = null + modifier = modifier, + painter = painter, + contentDescription = contentDescription ) } @Composable -fun Icon(imageVector: ImageVector, modifier: Modifier = Modifier) { +fun Icon( + imageVector: ImageVector, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { androidx.compose.material3.Icon( - modifier = modifier, imageVector = imageVector, contentDescription = null + modifier = modifier, + imageVector = imageVector, + contentDescription = contentDescription ) } @Composable -fun Icon(imageVector: ImageVector, tint: Color, modifier: Modifier = Modifier) { +fun Icon( + imageVector: ImageVector, + tint: Color, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { androidx.compose.material3.Icon( modifier = modifier, imageVector = imageVector, - tint = tint, - contentDescription = null + tint = tint, contentDescription = contentDescription ) } diff --git a/app/src/main/java/org/yrovas/linklater/ui/common/KeyboardRow.kt b/app/src/main/java/org/yrovas/linklater/ui/common/KeyboardRow.kt index 7488431..14c4422 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/common/KeyboardRow.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/common/KeyboardRow.kt @@ -26,10 +26,10 @@ import androidx.compose.ui.zIndex fun KeyboardRow(content: @Composable () -> Unit) { val isImeVisible = WindowInsets.isImeVisible val density = LocalDensity.current - val offsetY = - WindowInsets.ime.getBottom(density) - WindowInsets.systemBars.getBottom( - density - ) + + val offsetY = WindowInsets.ime.getBottom(density) - + // take into account padding from system bars (navigation pill/buttons) + WindowInsets.systemBars.getBottom(density) var previousOffset by remember { mutableStateOf(0) } @@ -54,11 +54,7 @@ fun KeyboardRow(content: @Composable () -> Unit) { contentAlignment = Alignment.BottomStart ) { Box(modifier = Modifier - .offset { - IntOffset(0, -offsetY) - } -// .height(100.dp) -// .border(5.dp, colorScheme.tertiaryContainer) + .offset { IntOffset(0, -offsetY) } .fillMaxWidth()) { content() } diff --git a/app/src/main/java/org/yrovas/linklater/ui/common/RefreshIcon.kt b/app/src/main/java/org/yrovas/linklater/ui/common/RefreshIcon.kt index 8f7ff43..e04cf87 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/common/RefreshIcon.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/common/RefreshIcon.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -21,13 +22,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch +import org.yrovas.linklater.ThemePreview +import org.yrovas.linklater.ui.theme.AppTheme +import kotlin.random.Random @Composable fun RefreshIcon( modifier: Modifier = Modifier, - refreshing: StateFlow, + isRefreshing: StateFlow, icon: ImageVector = Icons.Default.Refresh, tint: Color = MaterialTheme.colorScheme.primary, ) { @@ -36,35 +41,35 @@ fun RefreshIcon( val linearInFastOutEasing: Easing = remember { CubicBezierEasing(0.4f, 0.0f, 0.25f, 1.0f) } - val isRefreshing by refreshing.collectAsState() + val refreshing by isRefreshing.collectAsState() var didRefresh by remember { mutableStateOf(false) } val rotation = remember { Animatable(0f) } - LaunchedEffect(isRefreshing) { - launch { - val duration = 750 - val target = 360f - if (isRefreshing) { - didRefresh = true - rotation.animateTo( - targetValue = 360f, animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = duration, easing = slowInFastOutEasing - ), repeatMode = RepeatMode.Restart - ) + LaunchedEffect(refreshing) { + val duration = 750 + val target = 360f + if (refreshing) { + didRefresh = true + rotation.animateTo( + targetValue = 360f, animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = duration, easing = slowInFastOutEasing + ), repeatMode = RepeatMode.Restart ) - } else if (didRefresh) { - val remainingMillis: Int = ((target - rotation.value) / target * duration).toInt() - val easing = if (remainingMillis > (duration/2)) linearInFastOutEasing else LinearEasing - rotation.animateTo( - targetValue = 360f, - initialVelocity = rotation.velocity, - animationSpec = tween( - durationMillis = remainingMillis, easing = easing - ) + ) + } else if (didRefresh) { + val remainingMillis: Int = + ((target - rotation.value) / target * duration).toInt() + val easing = + if (remainingMillis > (duration / 2)) linearInFastOutEasing else LinearEasing + rotation.animateTo( + targetValue = 360f, + initialVelocity = rotation.velocity, + animationSpec = tween( + durationMillis = remainingMillis, easing = easing ) - rotation.snapTo(0f) - } + ) + rotation.snapTo(0f) } } @@ -74,3 +79,20 @@ fun RefreshIcon( modifier = modifier.rotate(rotation.value) ) } + +@ThemePreview +@Composable +fun PreviewRefreshIcon() { + val refreshing = remember { MutableStateFlow(false) } + LaunchedEffect(Unit) { + while (true) { + delay(Random.nextLong(1000, 4000)) + refreshing.emit(!refreshing.value) + } + } + AppTheme { + Surface { + RefreshIcon(isRefreshing = refreshing) + } + } +} diff --git a/app/src/main/java/org/yrovas/linklater/ui/common/TextPreference.kt b/app/src/main/java/org/yrovas/linklater/ui/common/TextPreference.kt index ecfd5cb..57784a5 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/common/TextPreference.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/common/TextPreference.kt @@ -5,15 +5,36 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.ContentPasteGo +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.runtime.* +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -93,7 +114,7 @@ private fun TextPreference( .fillMaxWidth() .padding(padding.standard) .clip(RoundedCornerShape(8.dp)) - .clickable { showDialog = true } + .clickable { showDialog = true } , ) { Column { @@ -181,7 +202,8 @@ private fun TextEditDialog( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .clickable { showInfo = !showInfo } .padding(padding.standard) ) { diff --git a/app/src/main/java/org/yrovas/linklater/ui/component/BookmarkRow.kt b/app/src/main/java/org/yrovas/linklater/ui/component/BookmarkRow.kt index 8dd0fe6..1050944 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/component/BookmarkRow.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/component/BookmarkRow.kt @@ -1,24 +1,30 @@ -package org.yrovas.linklater.ui.common +package org.yrovas.linklater.ui.component import android.content.Context +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow @@ -26,18 +32,25 @@ import androidx.core.net.toUri import io.ktor.http.Url import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import org.yrovas.linklater.ThemePreview import org.yrovas.linklater.data.Bookmark +import org.yrovas.linklater.data.showDescriptionOrElse +import org.yrovas.linklater.data.showTitleOrElse import org.yrovas.linklater.openUri import org.yrovas.linklater.timeAgo +import org.yrovas.linklater.ui.common.Icon +import org.yrovas.linklater.ui.theme.AppTheme import org.yrovas.linklater.ui.theme.padding @Composable -fun BookmarkRow( - bookmark: Bookmark, - context: Context = LocalContext.current, -) { +fun BookmarkRow(bookmark: Bookmark) { + + val context: Context = LocalContext.current + var selected by remember { mutableStateOf(false) } Column( - modifier = Modifier.padding(vertical = padding.standard), + modifier = Modifier + .padding(vertical = padding.standard) + .clickable { selected = !selected }, verticalArrangement = Arrangement.Center, ) { Row( @@ -58,36 +71,25 @@ fun BookmarkRow( } Spacer(modifier = Modifier.height(padding.tiny)) Row { - Text(text = if (!bookmark.title.isNullOrBlank()) { - bookmark.title - } else if (!bookmark.website_title.isNullOrBlank()) { - bookmark.website_title - } else { - bookmark.url.substringAfter("://") - }, + Text(text = bookmark.showTitleOrElse(bookmark.url.substringAfter("://")), overflow = TextOverflow.Ellipsis, - maxLines = 2, + maxLines = if (selected) 5 else 2, style = typography.titleLarge, color = colorScheme.primary, - modifier = Modifier.clickable { - context.openUri(bookmark.url.toUri()) - }) + modifier = Modifier + .clickable { context.openUri(bookmark.url.toUri()) } + .animateContentSize()) } if (!bookmark.description.isNullOrBlank() || !bookmark.website_description.isNullOrBlank()) { Spacer(modifier = Modifier.height(padding.half)) Row { Text( - text = if (!bookmark.description.isNullOrBlank()) { - bookmark.description - } else if (!bookmark.website_description.isNullOrBlank()) { - bookmark.website_description - } else { - "" - }, + text = bookmark.showDescriptionOrElse(""), overflow = TextOverflow.Ellipsis, - maxLines = 2, + maxLines = if (selected) 10 else 2, style = typography.bodyMedium, - color = colorScheme.outline + color = colorScheme.outline, + modifier = Modifier.animateContentSize() ) } } @@ -105,3 +107,34 @@ fun BookmarkRow( } } } + +@ThemePreview +@Composable +fun PreviewBookmarkRow() { + AppTheme { + Surface { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding.standard) + ) { + BookmarkRow( + bookmark = Bookmark( + 1, + url = "https://danielyrovas.com", + title = "Section 1.10.32 of 'de Finibus Bonorum et Malorum'", + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?", + shared = false, + unread = true, + is_archived = false, + date_added = "2024-04-21T13:38:40.360183Z", + date_modified = "2024-04-21T13:38:40.360185Z", + tags = listOf( + "tag", "myself", "with", "the", "best", "tags" + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/yrovas/linklater/ui/component/DestinationHost.kt b/app/src/main/java/org/yrovas/linklater/ui/component/DestinationHost.kt index e5a94a5..e7859cd 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/component/DestinationHost.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/component/DestinationHost.kt @@ -1,4 +1,4 @@ -package org.yrovas.linklater.ui.common +package org.yrovas.linklater.ui.component import android.annotation.SuppressLint import androidx.compose.material3.SnackbarHostState diff --git a/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt b/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt index d4bf0d5..b712cf3 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/screens/HomeScreen.kt @@ -26,10 +26,9 @@ import com.ramcosta.composedestinations.generated.destinations.PreferencesScreen import com.ramcosta.composedestinations.generated.destinations.SaveBookmarkScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.launch -import org.yrovas.linklater.domain.APIError import org.yrovas.linklater.show import org.yrovas.linklater.ui.common.AppBar -import org.yrovas.linklater.ui.common.BookmarkRow +import org.yrovas.linklater.ui.component.BookmarkRow import org.yrovas.linklater.ui.common.Frame import org.yrovas.linklater.ui.common.Icon import org.yrovas.linklater.ui.common.RefreshIcon @@ -70,7 +69,7 @@ fun HomeScreen( Frame(appBar = { AppBar(page = "Bookmarks", back = null) { IconButton(onClick = { state.sendEvent(Event.RefreshBookmarks) }) { - RefreshIcon(refreshing = state.isRefreshing) + RefreshIcon(isRefreshing = state.isRefreshing) } IconButton(onClick = { nav.navigate(PreferencesScreenDestination) }) { Icon( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0ccf0a..f7fa7a4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] app-versionID = "org.yrovas.linklater" -app-versionCode = "10" -app-versionName = "0.1.9" +app-versionCode = "11" +app-versionName = "0.1.10" app-compileSDK = "34" app-targetSDK = "34" app-minimumSDK = "23"