diff --git a/.github/workflows/ios_prod_release.yml b/.github/workflows/ios_prod_release.yml index ee7f6fe8e..4b02009d1 100644 --- a/.github/workflows/ios_prod_release.yml +++ b/.github/workflows/ios_prod_release.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: tramline-macos-sonoma-md + runs-on: macos-latest env: TERM: dumb SENTRY_DSN: ${{ secrets.SENTRY_DSN }} diff --git a/.github/workflows/ios_prod_release_test.yml b/.github/workflows/ios_prod_release_test.yml new file mode 100644 index 000000000..b344f2798 --- /dev/null +++ b/.github/workflows/ios_prod_release_test.yml @@ -0,0 +1,120 @@ +name: iOS Prod Release Test + +on: + workflow_dispatch: + inputs: + tramline-input: + description: "Tramline input" + required: false + +jobs: + build: + runs-on: tramline-macos-sonoma-md + env: + TERM: dumb + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + steps: + - name: Configure Tramline + id: tramline + uses: tramlinehq/deploy-action@v0.1.6 + with: + input: ${{ github.event.inputs.tramline-input }} + + - name: Setup JDK 20 + uses: actions/setup-java@v4 + with: + java-version: 20 + distribution: zulu + cache: 'gradle' + + - name: Install private API key P8 + env: + PRIVATE_API_KEY_BASE64: ${{ secrets.APP_STORE_API_PRIVATE_KEY }} + API_KEY: ${{ secrets.APP_STORE_KEY_ID }} + run: | + mkdir -p ~/private_keys + echo -n "$PRIVATE_API_KEY_BASE64" | base64 --decode --output ~/private_keys/AuthKey_$API_KEY.p8 + + - name: Install the Apple certificate and provisioning profile + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH + echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH + + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + + - name: Update Archive Version + run: | + /usr/libexec/Plistbuddy -c "Set CFBundleVersion ${{ steps.tramline.outputs.version_code }}" "iosApp/iosApp/Info.plist" + /usr/libexec/Plistbuddy -c "Set CFBundleShortVersionString ${{ steps.tramline.outputs.version_name }}" "iosApp/iosApp/Info.plist" + + - name: Pod Install + run: | + ./gradlew --no-daemon podInstall; + + - name: Build Archive + run: | + xcodebuild -workspace ./iosApp/iosApp.xcworkspace \ + -scheme iosApp \ + -archivePath $RUNNER_TEMP/twine.xcarchive \ + -sdk iphoneos \ + -configuration Release \ + -destination generic/platform=iOS \ + DEVELOPMENT_TEAM=6XCS8KZXDA \ + PROVISIONING_PROFILE=${{ secrets.PROVISION_PROFILE_ID }} \ + clean archive + CODE_SIGN_IDENTITY="iPhone Distribution: Sasi Kanth (6XCS8KZXDA)" + + - name: Export ipa + env: + EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST }} + run: | + EXPORT_OPTS_PATH=$RUNNER_TEMP/ExportOptions.plist + echo -n "$EXPORT_OPTIONS_PLIST" | base64 --decode -o $EXPORT_OPTS_PATH + xcodebuild -exportArchive -archivePath $RUNNER_TEMP/twine.xcarchive -exportOptionsPlist $EXPORT_OPTS_PATH -exportPath $RUNNER_TEMP/build + + - name: Upload debug symbols to Sentry + run: | + curl -sL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION=2.21.2 bash; + + sentry-cli debug-files upload --auth-token ${{ secrets.SENTRY_AUTH_TOKEN }} \ + --include-sources \ + --org ${{ secrets.SENTRY_ORG }} \ + --project ${{ secrets.SENTRY_PROJECT }} \ + $RUNNER_TEMP/twine.xcarchive/dSYMs + + - name: Clean up keychain and provisioning profile + if: ${{ always() }} + run: | + security delete-keychain $RUNNER_TEMP/app-signing.keychain-db + rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision + + - name: Upload app to TestFlight + env: + API_KEY: ${{ secrets.APP_STORE_KEY_ID }} + API_ISSUER: ${{ secrets.APP_STORE_ISSUER_ID }} + APP_PATH: ${{ runner.temp }}/build/twine.ipa + run: | + xcrun altool --upload-app --type ios -f $APP_PATH --apiKey $API_KEY --apiIssuer $API_ISSUER + + - name: Upload application + uses: actions/upload-artifact@v4 + with: + name: app + path: ${{ runner.temp }}/build/twine.ipa diff --git a/README.md b/README.md index 3e8e20b85..5140a4ea1 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,15 @@ user interface and experience to browse through the feeds, and supports Material ## Features ✨ - Supports RSS & Atom feeds -- Bookmarks -- Search +- Gorgeous home feed +- Pin frequently visited feeds +- Smart fetching: Twine looks for feeds when given any website homepage +- Reading view with shortcut to fetch full article +- Bookmark posts to read later +- Search posts - Background sync - Feed management: Add, Edit & Pin feeds -- Reader view with article fetching +- Import and exports your feeds with OPML ## Tech Stack 📚 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab67ab966..1b0615091 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ stately = "2.0.6" xmlutil = "0.86.3" ktxml = "0.2.3" uri = "0.0.16" -webview = "1.8.4" +webview = "1.8.6" uuid = "0.8.2" [libraries] @@ -56,6 +56,7 @@ compose_ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" } compose_ui_util = { module = "org.jetbrains.compose.ui:ui-util", version.ref = "compose" } compose_material = { module = "org.jetbrains.compose.material:material", version.ref = "compose" } compose_material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose" } +compose_material_icons_extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose" } compose_resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose" } ktor_core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor_client_okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } @@ -127,7 +128,7 @@ buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" sentry_android = { id = "io.sentry.android.gradle", version.ref = "sentry_android" } [bundles] -compose = [ "compose_runtime", "compose_foundation", "compose_material", "compose_material3", "compose_resources", "compose_ui", "compose_ui_util" ] +compose = [ "compose_runtime", "compose_foundation", "compose_material", "compose_material3", "compose_resources", "compose_ui", "compose_ui_util", "compose_material_icons_extended" ] kotlinx = [ "kotlinx_coroutines", "kotlinx_datetime", "kotlinx_immutable_collections" ] androidx_test = [ "androidx_test_runner", "androidx_test_rules" ] xmlutil = [ "xmlutil-core", "xmlutil-serialization" ] diff --git a/readme_images/banner.png b/readme_images/banner.png index 0e54447d3..5c16bab57 100644 Binary files a/readme_images/banner.png and b/readme_images/banner.png differ diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 3b75b5cc8..ef038eef6 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -111,5 +111,7 @@ val EnTwineStrings = postsToday = "Today", openSource = "Open Source", openSourceDesc = - "Twine is built on open source technologies and is completely free to use, you can find the source code of Twine and some of my other popular projects on GitHub. Click here to head over there." + "Twine is built on open source technologies and is completely free to use, you can find the source code of Twine and some of my other popular projects on GitHub. Click here to head over there.", + markAsRead = "Mark as Read", + markAsUnRead = "Mark as Unread" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index 2b02e4960..6df63b744 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -101,6 +101,8 @@ data class TwineStrings( val postsToday: String, val openSource: String, val openSourceDesc: String, + val markAsRead: String, + val markAsUnRead: String ) object Locales { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksEvent.kt index 7eb4570f9..dfec4f611 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksEvent.kt @@ -26,4 +26,6 @@ sealed interface BookmarksEvent { data class OnPostBookmarkClick(val post: PostWithMetadata) : BookmarksEvent data class OnPostClicked(val post: PostWithMetadata) : BookmarksEvent + + data class TogglePostReadStatus(val postLink: String, val postRead: Boolean) : BookmarksEvent } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksPresenter.kt index bfb316e92..fc3805f28 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/BookmarksPresenter.kt @@ -103,6 +103,14 @@ class BookmarksPresenter( is BookmarksEvent.OnPostClicked -> { // no-op } + is BookmarksEvent.TogglePostReadStatus -> + togglePostReadStatus(event.postLink, event.postRead) + } + } + + private fun togglePostReadStatus(postLink: String, postRead: Boolean) { + coroutineScope.launch { + rssRepository.updatePostReadStatus(read = !postRead, link = postLink) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/ui/BookmarksScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/ui/BookmarksScreen.kt index 7c5213798..131560757 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/ui/BookmarksScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/bookmarks/ui/BookmarksScreen.kt @@ -136,6 +136,11 @@ internal fun BookmarksScreen( }, onPostSourceClick = { // no-op + }, + togglePostReadClick = { + bookmarksPresenter.dispatch( + BookmarksEvent.TogglePostReadStatus(post.link, post.read) + ) } ) if (index != bookmarks.itemCount - 1) { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt index 29b13e655..f6d843b68 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt @@ -59,4 +59,6 @@ sealed interface HomeEvent { data object EditFeedsClicked : HomeEvent data object ExitFeedsEdit : HomeEvent + + data class TogglePostReadStatus(val postLink: String, val postRead: Boolean) : HomeEvent } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt index 88a1ed371..411b29ab8 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt @@ -174,6 +174,13 @@ class HomePresenter( HomeEvent.ExitFeedsEdit -> exitFeedsEdit() is HomeEvent.OnPostSourceClicked -> postSourceClicked(event.feedLink) is HomeEvent.OnPostsTypeChanged -> onPostsTypeChanged(event.postsType) + is HomeEvent.TogglePostReadStatus -> togglePostReadStatus(event.postLink, event.postRead) + } + } + + private fun togglePostReadStatus(postLink: String, postRead: Boolean) { + coroutineScope.launch { + rssRepository.updatePostReadStatus(read = !postRead, link = postLink) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt index 65c9f8d09..519f109ac 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt @@ -68,7 +68,8 @@ internal fun FeaturedPostItem( onClick: () -> Unit, onBookmarkClick: () -> Unit, onCommentsClick: () -> Unit, - onSourceClick: () -> Unit + onSourceClick: () -> Unit, + onTogglePostReadClick: () -> Unit, ) { val isLargeScreenLayout = LocalWindowSizeClass.current.widthSizeClass == WindowWidthSizeClass.Expanded @@ -85,7 +86,8 @@ internal fun FeaturedPostItem( pagerState = pagerState, onBookmarkClick = onBookmarkClick, onCommentsClick = onCommentsClick, - onSourceClick = onSourceClick + onSourceClick = onSourceClick, + onTogglePostReadClick = onTogglePostReadClick ) } else { DefaultFeaturedPostItem( @@ -94,7 +96,8 @@ internal fun FeaturedPostItem( pagerState = pagerState, onBookmarkClick = onBookmarkClick, onCommentsClick = onCommentsClick, - onSourceClick = onSourceClick + onSourceClick = onSourceClick, + onTogglePostReadClick = onTogglePostReadClick ) } } @@ -107,7 +110,8 @@ private fun DefaultFeaturedPostItem( pagerState: PagerState, onBookmarkClick: () -> Unit, onCommentsClick: () -> Unit, - onSourceClick: () -> Unit + onSourceClick: () -> Unit, + onTogglePostReadClick: () -> Unit, ) { Column { AsyncImage( @@ -167,6 +171,7 @@ private fun DefaultFeaturedPostItem( onBookmarkClick = onBookmarkClick, onCommentsClick = onCommentsClick, onSourceClick = onSourceClick, + onTogglePostReadClick = onTogglePostReadClick, modifier = Modifier.padding(start = 16.dp, end = 0.dp) ) @@ -181,7 +186,8 @@ private fun LargeScreenFeaturedPostItem( pagerState: PagerState, onBookmarkClick: () -> Unit, onCommentsClick: () -> Unit, - onSourceClick: () -> Unit + onSourceClick: () -> Unit, + onTogglePostReadClick: () -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { AsyncImage( @@ -241,7 +247,8 @@ private fun LargeScreenFeaturedPostItem( enablePostSource = true, onBookmarkClick = onBookmarkClick, onCommentsClick = onCommentsClick, - onSourceClick = onSourceClick + onSourceClick = onSourceClick, + onTogglePostReadClick = onTogglePostReadClick ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt index 218c0d80e..878c556e4 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt @@ -97,6 +97,7 @@ internal fun FeaturedSection( onPostBookmarkClick: (PostWithMetadata) -> Unit, onPostCommentsClick: (String) -> Unit, onPostSourceClick: (String) -> Unit, + onTogglePostReadClick: (String, Boolean) -> Unit, ) { Box(modifier = modifier) { if (featuredPosts.isNotEmpty()) { @@ -179,7 +180,8 @@ internal fun FeaturedSection( onClick = { onItemClick(featuredPost) }, onBookmarkClick = { onPostBookmarkClick(featuredPost) }, onCommentsClick = { onPostCommentsClick(featuredPost.commentsLink!!) }, - onSourceClick = { onPostSourceClick(featuredPost.feedLink) } + onSourceClick = { onPostSourceClick(featuredPost.feedLink) }, + onTogglePostReadClick = { onTogglePostReadClick(featuredPost.link, featuredPost.read) } ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt index 08f83e9f6..b51f5fbc7 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt @@ -186,6 +186,9 @@ internal fun HomeScreen(homePresenter: HomePresenter, modifier: Modifier = Modif homePresenter.dispatch(HomeEvent.OnPostSourceClicked(feedLink)) }, onNoFeedsSwipeUp = { coroutineScope.launch { bottomSheetState.expand() } }, + onTogglePostReadStatus = { postLink, postRead -> + homePresenter.dispatch(HomeEvent.TogglePostReadStatus(postLink, postRead)) + } ) }, sheetContent = { @@ -278,6 +281,7 @@ private fun HomeScreenContent( onPostCommentsClick: (String) -> Unit, onPostSourceClick: (String) -> Unit, onNoFeedsSwipeUp: () -> Unit, + onTogglePostReadStatus: (String, Boolean) -> Unit, ) { val featuredPosts = state.featuredPosts val posts = state.posts?.collectAsLazyPagingItems() @@ -312,6 +316,7 @@ private fun HomeScreenContent( onPostBookmarkClick = onPostBookmarkClick, onPostCommentsClick = onPostCommentsClick, onPostSourceClick = onPostSourceClick, + onTogglePostReadClick = onTogglePostReadStatus ) } !hasFeeds -> { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt index 7398d8e1e..4b91a157e 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt @@ -75,7 +75,8 @@ internal fun PostsList( onPostClicked: (post: PostWithMetadata) -> Unit, onPostBookmarkClick: (PostWithMetadata) -> Unit, onPostCommentsClick: (String) -> Unit, - onPostSourceClick: (String) -> Unit + onPostSourceClick: (String) -> Unit, + onTogglePostReadClick: (String, Boolean) -> Unit, ) { val topContentPadding = if (featuredPosts.isEmpty()) { @@ -97,7 +98,8 @@ internal fun PostsList( onItemClick = onPostClicked, onPostBookmarkClick = onPostBookmarkClick, onPostCommentsClick = onPostCommentsClick, - onPostSourceClick = onPostSourceClick + onPostSourceClick = onPostSourceClick, + onTogglePostReadClick = onTogglePostReadClick ) } @@ -111,7 +113,8 @@ internal fun PostsList( onClick = { onPostClicked(post) }, onPostBookmarkClick = { onPostBookmarkClick(post) }, onPostCommentsClick = { onPostCommentsClick(post.commentsLink!!) }, - onPostSourceClick = { onPostSourceClick(post.feedLink) } + onPostSourceClick = { onPostSourceClick(post.feedLink) }, + togglePostReadClick = { onTogglePostReadClick(post.link, post.read) } ) if (index != posts.itemCount - 1) { @@ -133,6 +136,7 @@ fun PostListItem( onPostBookmarkClick: () -> Unit, onPostCommentsClick: () -> Unit, onPostSourceClick: () -> Unit, + togglePostReadClick: () -> Unit, reduceReadItemAlpha: Boolean = false ) { Column( @@ -179,6 +183,7 @@ fun PostListItem( onBookmarkClick = onPostBookmarkClick, onCommentsClick = onPostCommentsClick, onSourceClick = onPostSourceClick, + onTogglePostReadClick = togglePostReadClick, modifier = Modifier.padding(start = 24.dp, end = 12.dp) ) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostMetadata.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostMetadata.kt index f3b4e5fc5..889bea946 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostMetadata.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostMetadata.kt @@ -18,18 +18,32 @@ package dev.sasikanth.rss.reader.home.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,11 +53,12 @@ import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import dev.sasikanth.rss.reader.components.DropdownMenu import dev.sasikanth.rss.reader.resources.icons.Bookmark import dev.sasikanth.rss.reader.resources.icons.Bookmarked import dev.sasikanth.rss.reader.resources.icons.Comments -import dev.sasikanth.rss.reader.resources.icons.Share import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.share.LocalShareHandler @@ -59,6 +74,7 @@ internal fun PostMetadata( commentsLink: String?, onBookmarkClick: () -> Unit, onCommentsClick: () -> Unit, + onTogglePostReadClick: () -> Unit, modifier: Modifier = Modifier, enablePostSource: Boolean, onSourceClick: () -> Unit, @@ -122,9 +138,11 @@ internal fun PostMetadata( PostOptionsButtonRow( postLink = postLink, postBookmarked = postBookmarked, + postRead = postRead, commentsLink = commentsLink, onBookmarkClick = onBookmarkClick, - onCommentsClick = onCommentsClick + onCommentsClick = onCommentsClick, + togglePostReadClick = onTogglePostReadClick ) } } @@ -133,9 +151,11 @@ internal fun PostMetadata( private fun PostOptionsButtonRow( postLink: String, postBookmarked: Boolean, + postRead: Boolean, commentsLink: String?, onBookmarkClick: () -> Unit, - onCommentsClick: () -> Unit + onCommentsClick: () -> Unit, + togglePostReadClick: () -> Unit ) { Row { if (!commentsLink.isNullOrBlank()) { @@ -164,12 +184,78 @@ private fun PostOptionsButtonRow( onClick = onBookmarkClick ) - val shareHandler = LocalShareHandler.current - PostOptionIconButton( - icon = TwineIcons.Share, - contentDescription = LocalStrings.current.share, - onClick = { shareHandler.share(postLink) } - ) + var showDropdown by remember { mutableStateOf(false) } + Box { + PostOptionIconButton( + icon = Icons.Filled.MoreVert, + contentDescription = LocalStrings.current.moreMenuOptions, + onClick = { showDropdown = true } + ) + + DropdownMenu( + modifier = Modifier.width(IntrinsicSize.Min), + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + offset = DpOffset(x = 0.dp, y = (-48).dp), + ) { + DropdownMenuItem( + modifier = Modifier.fillMaxWidth(), + text = { + val label = + if (postRead) { + LocalStrings.current.markAsUnRead + } else { + LocalStrings.current.markAsRead + } + + Text(text = label, color = AppTheme.colorScheme.onSurface, textAlign = TextAlign.Start) + }, + leadingIcon = { + val icon = + if (postRead) { + Icons.Outlined.CheckCircle + } else { + Icons.Filled.CheckCircle + } + + Icon( + icon, + contentDescription = null, + tint = AppTheme.colorScheme.onSurface, + ) + }, + onClick = { + togglePostReadClick() + showDropdown = false + } + ) + + Divider(modifier = Modifier.padding(vertical = 4.dp)) + + val shareHandler = LocalShareHandler.current + DropdownMenuItem( + modifier = Modifier.fillMaxWidth(), + text = { + Text( + text = LocalStrings.current.share, + color = AppTheme.colorScheme.onSurface, + textAlign = TextAlign.Start + ) + }, + leadingIcon = { + Icon( + Icons.Filled.Share, + contentDescription = null, + tint = AppTheme.colorScheme.onSurface, + ) + }, + onClick = { + shareHandler.share(postLink) + showDropdown = false + } + ) + } + } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt index d6a3fcb48..89db02428 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt @@ -202,8 +202,6 @@ internal fun ReaderScreen(presenter: ReaderPresenter, modifier: Modifier = Modif } } state.hasContent -> { - val backgroundColor = - StringUtils.hexFromArgb(AppTheme.colorScheme.surfaceContainerLowest.toArgb()) val codeBackgroundColor = StringUtils.hexFromArgb(AppTheme.colorScheme.surfaceContainerHighest.toArgb()) val textColor = StringUtils.hexFromArgb(AppTheme.colorScheme.onSurface.toArgb()) @@ -230,7 +228,6 @@ internal fun ReaderScreen(presenter: ReaderPresenter, modifier: Modifier = Modif