From 614d62dc5838d8231bb91d38ede6f45f503ea1ab Mon Sep 17 00:00:00 2001 From: Xilin Jia <6257601+XilinJia@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:57:28 +0100 Subject: [PATCH] 6.9.2 commit --- app/build.gradle | 5 +- .../ac/mdiq/podcini/net/feed/FeedBuilder.kt | 4 +- .../mdiq/podcini/net/feed/LocalFeedUpdater.kt | 41 ++++- .../podcini/net/sync/wifi/WifiSyncService.kt | 2 +- .../ImportExportPreferencesFragment.kt | 3 +- .../SynchronizationPreferencesFragment.kt | 43 ++++- .../mdiq/podcini/storage/database/Episodes.kt | 12 +- .../mdiq/podcini/storage/database/RealmDB.kt | 22 ++- .../ac/mdiq/podcini/storage/model/Episode.kt | 34 +++- .../utils/ChapterStartTimeComparator.kt | 9 - .../podcini/storage/utils/ChapterUtils.kt | 6 + .../podcini/storage/utils/FastDocumentFile.kt | 36 ---- .../mdiq/podcini/ui/actions/SwipeActions.kt | 10 +- .../ac/mdiq/podcini/ui/compose/AppTheme.kt | 156 ++---------------- .../ac/mdiq/podcini/ui/compose/Composables.kt | 33 ++++ .../ac/mdiq/podcini/ui/compose/EpisodesVM.kt | 143 ++++++++-------- .../podcini/ui/dialog/AuthenticationDialog.kt | 50 ------ .../ui/fragment/AudioPlayerFragment.kt | 4 +- .../ui/fragment/BaseEpisodesFragment.kt | 2 +- .../ui/fragment/EpisodeInfoFragment.kt | 133 +++++++-------- .../ui/fragment/FeedEpisodesFragment.kt | 7 + .../ui/fragment/SubscriptionsFragment.kt | 14 +- .../kotlin/ac/mdiq/podcini/util/FlowEvent.kt | 2 +- ...{feeditem_options.xml => episode_info.xml} | 0 app/src/main/res/menu/feed_episodes.xml | 16 +- app/src/main/res/values/strings.xml | 5 +- .../mdiq/podcini/feed/LocalFeedUpdaterTest.kt | 15 +- changelog.md | 11 ++ .../android/en-US/changelogs/3020266.txt | 2 +- .../android/en-US/changelogs/3020267.txt | 10 ++ gradle/libs.versions.toml | 2 + 31 files changed, 406 insertions(+), 426 deletions(-) delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterStartTimeComparator.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/storage/utils/FastDocumentFile.kt delete mode 100644 app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/AuthenticationDialog.kt rename app/src/main/res/menu/{feeditem_options.xml => episode_info.xml} (100%) create mode 100644 fastlane/metadata/android/en-US/changelogs/3020267.txt diff --git a/app/build.gradle b/app/build.gradle index d9c1f080..52da72cd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020266 - versionName "6.9.1" + versionCode 3020267 + versionName "6.9.2" applicationId "ac.mdiq.podcini.R" def commit = "" @@ -174,6 +174,7 @@ dependencies { implementation libs.androidx.material3 // implementation libs.androidx.ui.viewbinding implementation libs.androidx.fragment.compose + implementation libs.androidx.material.icons.extended /** Desugaring for using VistaGuide **/ coreLibraryDesugaring libs.desugar.jdk.libs.nio diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt index 2bf3ced7..babf8c5d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt @@ -77,6 +77,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) e.feedId = feed_.id eList.add(e) } + feed_.episodes.addAll(eList) if (nextPage == null || feed_.episodes.size > 1000) break try { val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break @@ -88,7 +89,6 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) withContext(Dispatchers.Main) { showError(e.message, "") } break } - feed_.episodes.addAll(eList) } feed_.isBuilding = false } @@ -123,6 +123,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) e.feedId = feed_.id eList.add(e) } + feed_.episodes.addAll(eList) if (nextPage == null || feed_.episodes.size > 1000) break try { val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage) @@ -134,7 +135,6 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) withContext(Dispatchers.Main) { showError(e.message, "") } break } - feed_.episodes.addAll(eList) } feed_.isBuilding = false } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/LocalFeedUpdater.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/LocalFeedUpdater.kt index ef93b6af..ebabf73d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/LocalFeedUpdater.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/LocalFeedUpdater.kt @@ -1,31 +1,26 @@ package ac.mdiq.podcini.net.feed import ac.mdiq.podcini.R +import ac.mdiq.podcini.net.download.DownloadError import ac.mdiq.podcini.net.feed.parser.media.id3.ID3ReaderException import ac.mdiq.podcini.net.feed.parser.media.id3.Id3MetadataReader import ac.mdiq.podcini.net.feed.parser.media.vorbis.VorbisCommentMetadataReader import ac.mdiq.podcini.net.feed.parser.media.vorbis.VorbisCommentReaderException -import ac.mdiq.podcini.net.download.DownloadError import ac.mdiq.podcini.net.feed.parser.utils.DateUtils import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils import ac.mdiq.podcini.storage.database.Feeds import ac.mdiq.podcini.storage.database.LogsAndStats +import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat -import ac.mdiq.podcini.storage.model.DownloadResult -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.storage.utils.FastDocumentFile import ac.mdiq.podcini.util.Logd import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri +import android.provider.DocumentsContract import androidx.annotation.VisibleForTesting import androidx.documentfile.provider.DocumentFile import androidx.media3.common.util.UnstableApi import org.apache.commons.io.input.CountingInputStream - import java.io.BufferedInputStream import java.io.IOException import java.text.ParseException @@ -227,4 +222,34 @@ object LocalFeedUpdater { fun interface UpdaterProgressListener { fun onLocalFileScanned(scanned: Int, totalFiles: Int) } + + /** + * Android's DocumentFile is slow because every single method call queries the ContentResolver. + * This queries the ContentResolver a single time with all the information. + */ + class FastDocumentFile(val name: String, val type: String, val uri: Uri, val length: Long, val lastModified: Long) { + companion object { + @JvmStatic + fun list(context: Context, folderUri: Uri?): List { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, DocumentsContract.getDocumentId(folderUri)) + val cursor = context.contentResolver.query(childrenUri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_MIME_TYPE), null, null, null) + val list = ArrayList() + while (cursor!!.moveToNext()) { + val id = cursor.getString(0) + val uri = DocumentsContract.buildDocumentUriUsingTree(folderUri, id) + val name = cursor.getString(1) + val size = cursor.getLong(2) + val lastModified = cursor.getLong(3) + val mimeType = cursor.getString(4) + list.add(FastDocumentFile(name, mimeType, uri, size, lastModified)) + } + cursor.close() + return list + } + } + } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt index 5a01150a..57198f42 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/sync/wifi/WifiSyncService.kt @@ -333,7 +333,7 @@ import kotlin.math.min it.media!!.setPosition(action.position * 1000) it.media!!.playedDuration = action.playedDuration * 1000 it.media!!.setLastPlayedTime(action.timestamp!!.time) - it.isFavorite = action.isFavorite + it.rating = if (action.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code it.playState = action.playState if (hasAlmostEnded(it.media!!)) { Logd(TAG, "Marking as played") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt index 2200765d..4c81270c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/ImportExportPreferencesFragment.kt @@ -35,6 +35,7 @@ import android.os.Bundle import android.os.ParcelFileDescriptor import android.text.format.Formatter import android.util.Log +import android.util.Rational import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -941,7 +942,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { it.media!!.setPosition(action.position * 1000) it.media!!.playedDuration = action.playedDuration * 1000 it.media!!.setLastPlayedTime(action.timestamp!!.time) - it.isFavorite = action.isFavorite + it.rating = if (action.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code it.playState = action.playState if (hasAlmostEnded(it.media!!)) { Logd(TAG, "Marking as played: $action") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/SynchronizationPreferencesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/SynchronizationPreferencesFragment.kt index d936d6f4..f857ddb5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/SynchronizationPreferencesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/SynchronizationPreferencesFragment.kt @@ -18,7 +18,6 @@ import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.hostPort import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.startInstantSync import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName import ac.mdiq.podcini.ui.activity.PreferenceActivity -import ac.mdiq.podcini.ui.dialog.AuthenticationDialog import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent @@ -31,6 +30,8 @@ import android.net.wifi.WifiManager import android.os.Build import android.os.Bundle import android.text.format.DateUtils +import android.text.method.HideReturnsTransformationMethod +import android.text.method.PasswordTransformationMethod import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -239,6 +240,46 @@ class SynchronizationPreferencesFragment : PreferenceFragmentCompat() { (activity as PreferenceActivity).supportActionBar!!.subtitle = status } + /** + * Displays a dialog with a username and password text field and an optional checkbox to save username and preferences. + */ + abstract class AuthenticationDialog(context: Context, titleRes: Int, enableUsernameField: Boolean, usernameInitialValue: String?, passwordInitialValue: String?) + : MaterialAlertDialogBuilder(context) { + + var passwordHidden: Boolean = true + + init { + setTitle(titleRes) + val viewBinding = AuthenticationDialogBinding.inflate(LayoutInflater.from(context)) + setView(viewBinding.root) + + viewBinding.usernameEditText.isEnabled = enableUsernameField + if (usernameInitialValue != null) viewBinding.usernameEditText.setText(usernameInitialValue) + if (passwordInitialValue != null) viewBinding.passwordEditText.setText(passwordInitialValue) + + viewBinding.showPasswordButton.setOnClickListener { + if (passwordHidden) { + viewBinding.passwordEditText.transformationMethod = HideReturnsTransformationMethod.getInstance() + viewBinding.showPasswordButton.alpha = 1.0f + } else { + viewBinding.passwordEditText.transformationMethod = PasswordTransformationMethod.getInstance() + viewBinding.showPasswordButton.alpha = 0.6f + } + passwordHidden = !passwordHidden + } + + setOnCancelListener { onCancelled() } + setNegativeButton(R.string.cancel_label) { _: DialogInterface?, _: Int -> onCancelled() } + setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + onConfirmed(viewBinding.usernameEditText.text.toString(), viewBinding.passwordEditText.text.toString()) + } + } + + protected open fun onCancelled() {} + + protected abstract fun onConfirmed(username: String, password: String) + } + /** * Guides the user through the authentication process. */ diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt index b51be016..a83c8219 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Episodes.kt @@ -251,8 +251,16 @@ object Episodes { fun setFavorite(episode: Episode, stat: Boolean?) : Job { Logd(TAG, "setFavorite called $stat") return runOnIOScope { - val result = upsert(episode) { it.isFavorite = stat ?: !it.isFavorite } - EventFlow.postEvent(FlowEvent.FavoritesEvent(result)) + val result = upsert(episode) { it.rating = if (stat ?: !it.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code } + EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating)) + } + } + + fun setRating(episode: Episode, rating: Int) : Job { + Logd(TAG, "setRating called $rating") + return runOnIOScope { + val result = upsert(episode) { it.rating = rating } + EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating)) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt index d856bb2c..3e72d8bc 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt @@ -9,7 +9,11 @@ import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.UpdatePolicy +import io.realm.kotlin.dynamic.DynamicMutableRealmObject +import io.realm.kotlin.dynamic.DynamicRealmObject +import io.realm.kotlin.dynamic.getValue import io.realm.kotlin.ext.isManaged +import io.realm.kotlin.migration.AutomaticSchemaMigration import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.TypedRealmObject import kotlinx.coroutines.* @@ -18,8 +22,6 @@ import kotlin.coroutines.ContinuationInterceptor object RealmDB { private val TAG: String = RealmDB::class.simpleName ?: "Anonymous" - private const val SCHEMA_VERSION_NUMBER = 24L - private val ioScope = CoroutineScope(Dispatchers.IO) val realm: Realm @@ -38,7 +40,21 @@ object RealmDB { ShareLog::class, Chapter::class)) .name("Podcini.realm") - .schemaVersion(SCHEMA_VERSION_NUMBER) + .schemaVersion(25) + .migration({ mContext -> + val oldRealm = mContext.oldRealm // old realm using the previous schema + val newRealm = mContext.newRealm // new realm using the new schema + if (oldRealm.schemaVersion() < 25) { + mContext.enumerate(className = "Episode") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? -> + newObject?.run { + set( + "rating", + if (oldObject.getValue(fieldName = "isFavorite")) 2L else 0L + ) + } + } + } + }) .build() realm = Realm.open(config) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt index c3369d40..c3c3bc6b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt @@ -1,5 +1,6 @@ package ac.mdiq.podcini.storage.model +import ac.mdiq.podcini.R import ac.mdiq.podcini.storage.database.Feeds.getFeed import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.stream.StreamInfo @@ -83,10 +84,13 @@ class Episode : RealmObject { */ var chapters: RealmList = realmListOf() - var isFavorite: Boolean = false + var rating: Int = Rating.NEUTRAL.code - // 0 : neutral, -1 : dislike, 1 : like - var opinion: Int = 0 + @Ignore + var isFavorite: Boolean = (rating == 2) + private set + + var comment: String = "" @Ignore val isNew: Boolean @@ -281,7 +285,7 @@ class Episode : RealmObject { if (isAutoDownloadEnabled != other.isAutoDownloadEnabled) return false if (tags != other.tags) return false if (chapters != other.chapters) return false - if (isFavorite != other.isFavorite) return false + if (rating != other.rating) return false if (isInProgress != other.isInProgress) return false if (isDownloaded != other.isDownloaded) return false @@ -305,12 +309,31 @@ class Episode : RealmObject { result = 31 * result + isAutoDownloadEnabled.hashCode() result = 31 * result + tags.hashCode() result = 31 * result + chapters.hashCode() - result = 31 * result + isFavorite.hashCode() + result = 31 * result + rating.hashCode() result = 31 * result + isInProgress.hashCode() result = 31 * result + isDownloaded.hashCode() return result } + fun shiftRating(): Int { + val nr = rating + 1 + return if (nr <= Rating.FAVORITE.code) nr else Rating.TRASH.code + } + + enum class Rating(val code: Int, val res: Int) { + TRASH(-2, R.drawable.ic_delete), + BAD(-1, androidx.media3.session.R.drawable.media3_icon_thumb_down_filled), + NEUTRAL(0, R.drawable.ic_questionmark), + GOOD(1, androidx.media3.session.R.drawable.media3_icon_thumb_up_filled), + FAVORITE(2, R.drawable.ic_star); + + companion object { + fun fromCode(code: Int): Rating { + return enumValues().firstOrNull { it.code == code } ?: NEUTRAL + } + } + } + enum class PlayState(val code: Int) { UNSPECIFIED(-2), NEW(-1), @@ -319,6 +342,7 @@ class Episode : RealmObject { BUILDING(2), ABANDONED(3) } + companion object { val TAG: String = Episode::class.simpleName ?: "Anonymous" } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterStartTimeComparator.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterStartTimeComparator.kt deleted file mode 100644 index ee97e8a5..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterStartTimeComparator.kt +++ /dev/null @@ -1,9 +0,0 @@ -package ac.mdiq.podcini.storage.utils - -import ac.mdiq.podcini.storage.model.Chapter - -class ChapterStartTimeComparator : Comparator { - override fun compare(lhs: Chapter, rhs: Chapter): Int { - return lhs.start.compareTo(rhs.start) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt index a02c3225..ca1835ea 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt @@ -245,4 +245,10 @@ object ChapterUtils { } return listOf() } + + class ChapterStartTimeComparator : Comparator { + override fun compare(lhs: Chapter, rhs: Chapter): Int { + return lhs.start.compareTo(rhs.start) + } + } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/FastDocumentFile.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/FastDocumentFile.kt deleted file mode 100644 index a1f409ab..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/FastDocumentFile.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ac.mdiq.podcini.storage.utils - -import android.content.Context -import android.net.Uri -import android.provider.DocumentsContract - -/** - * Android's DocumentFile is slow because every single method call queries the ContentResolver. - * This queries the ContentResolver a single time with all the information. - */ -class FastDocumentFile(val name: String, val type: String, val uri: Uri, val length: Long, val lastModified: Long) { - - companion object { - @JvmStatic - fun list(context: Context, folderUri: Uri?): List { - val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, DocumentsContract.getDocumentId(folderUri)) - val cursor = context.contentResolver.query(childrenUri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_SIZE, - DocumentsContract.Document.COLUMN_LAST_MODIFIED, - DocumentsContract.Document.COLUMN_MIME_TYPE), null, null, null) - val list = ArrayList() - while (cursor!!.moveToNext()) { - val id = cursor.getString(0) - val uri = DocumentsContract.buildDocumentUriUsingTree(folderUri, id) - val name = cursor.getString(1) - val size = cursor.getLong(2) - val lastModified = cursor.getLong(3) - val mimeType = cursor.getString(4) - list.add(FastDocumentFile(name, mimeType, uri, size, lastModified)) - } - cursor.close() - return list - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt index f09aa483..0f183ace 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt @@ -3,9 +3,9 @@ package ac.mdiq.podcini.ui.actions import ac.mdiq.podcini.R import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync -import ac.mdiq.podcini.storage.database.Episodes.setFavorite import ac.mdiq.podcini.storage.database.Episodes.setPlayState import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync +import ac.mdiq.podcini.storage.database.Episodes.setRating import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem import ac.mdiq.podcini.storage.database.Queues.addToQueue @@ -117,7 +117,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) @JvmField val swipeActions: List = listOf( NoActionSwipeAction(), AddToQueueSwipeAction(), - StartDownloadSwipeAction(), MarkFavoriteSwipeAction(), + StartDownloadSwipeAction(), ShiftRatingSwipeAction(), TogglePlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(), DeleteSwipeAction(), RemoveFromHistorySwipeAction()) @@ -203,7 +203,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } } - class MarkFavoriteSwipeAction : SwipeAction { + class ShiftRatingSwipeAction : SwipeAction { override fun getId(): String { return SwipeAction.MARK_FAV } @@ -217,12 +217,12 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } override fun getTitle(context: Context): String { - return context.getString(R.string.add_to_favorite_label) + return context.getString(R.string.switch_rating_label) } @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { - setFavorite(item, !item.isFavorite) + setRating(item, item.shiftRating()) } override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt index 7495d6d2..c6a21c6f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt @@ -1,6 +1,5 @@ package ac.mdiq.podcini.ui.compose -import ac.mdiq.podcini.R import ac.mdiq.podcini.preferences.ThemeSwitcher.readThemeValue import ac.mdiq.podcini.preferences.UserPreferences.ThemePreference import ac.mdiq.podcini.util.Logd @@ -9,13 +8,9 @@ import android.util.TypedValue import androidx.annotation.AttrRes import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Shapes -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -57,157 +52,26 @@ fun getColorFromAttr(context: Context, @AttrRes attrColor: Int): Int { } } -fun getPrimaryColor(context: Context): Color { - return Color(getColorFromAttr(context, R.attr.colorPrimary)) -} - -fun getSecondaryColor(context: Context): Color { - return Color(getColorFromAttr(context, R.attr.colorSecondary)) -} - -val md_theme_light_primary = Color(0xFF825500) -val md_theme_light_onPrimary = Color(0xFFFFFFFF) -val md_theme_light_primaryContainer = Color(0xFFFFDDB3) -val md_theme_light_onPrimaryContainer = Color(0xFF291800) -val md_theme_light_secondary = Color(0xFF6F5B40) -val md_theme_light_onSecondary = Color(0xFFFFFFFF) -val md_theme_light_secondaryContainer = Color(0xFFFBDEBC) -val md_theme_light_onSecondaryContainer = Color(0xFF271904) -val md_theme_light_tertiary = Color(0xFF51643F) -val md_theme_light_onTertiary = Color(0xFFFFFFFF) -val md_theme_light_tertiaryContainer = Color(0xFFD4EABB) -val md_theme_light_onTertiaryContainer = Color(0xFF102004) -val md_theme_light_error = Color(0xFFBA1A1A) -val md_theme_light_errorContainer = Color(0xFFFFDAD6) -val md_theme_light_onError = Color(0xFFFFFFFF) -val md_theme_light_onErrorContainer = Color(0xFF410002) -val md_theme_light_background = Color(0xFFFFFBFF) -val md_theme_light_onBackground = Color(0xFF1F1B16) -val md_theme_light_surface = Color(0xFFFFFBFF) -val md_theme_light_onSurface = Color(0xFF1F1B16) -val md_theme_light_surfaceVariant = Color(0xFFF0E0CF) -val md_theme_light_onSurfaceVariant = Color(0xFF4F4539) -val md_theme_light_outline = Color(0xFF817567) -val md_theme_light_inverseOnSurface = Color(0xFFF9EFE7) -val md_theme_light_inverseSurface = Color(0xFF34302A) -val md_theme_light_inversePrimary = Color(0xFFFFB951) -val md_theme_light_shadow = Color(0xFF000000) -val md_theme_light_surfaceTint = Color(0xFF825500) -val md_theme_light_outlineVariant = Color(0xFFD3C4B4) -val md_theme_light_scrim = Color(0xFF000000) - -val md_theme_dark_primary = Color(0xFFFFB951) -val md_theme_dark_onPrimary = Color(0xFF452B00) -val md_theme_dark_primaryContainer = Color(0xFF633F00) -val md_theme_dark_onPrimaryContainer = Color(0xFFFFDDB3) -val md_theme_dark_secondary = Color(0xFFDDC2A1) -val md_theme_dark_onSecondary = Color(0xFF3E2D16) -val md_theme_dark_secondaryContainer = Color(0xFF56442A) -val md_theme_dark_onSecondaryContainer = Color(0xFFFBDEBC) -val md_theme_dark_tertiary = Color(0xFFB8CEA1) -val md_theme_dark_onTertiary = Color(0xFF243515) -val md_theme_dark_tertiaryContainer = Color(0xFF3A4C2A) -val md_theme_dark_onTertiaryContainer = Color(0xFFD4EABB) -val md_theme_dark_error = Color(0xFFFFB4AB) -val md_theme_dark_errorContainer = Color(0xFF93000A) -val md_theme_dark_onError = Color(0xFF690005) -val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) -val md_theme_dark_background = Color(0xFF1F1B16) -val md_theme_dark_onBackground = Color(0xFFEAE1D9) -val md_theme_dark_surface = Color(0xFF1F1B16) -val md_theme_dark_onSurface = Color(0xFFEAE1D9) -val md_theme_dark_surfaceVariant = Color(0xFF4F4539) -val md_theme_dark_onSurfaceVariant = Color(0xFFD3C4B4) -val md_theme_dark_outline = Color(0xFF9C8F80) -val md_theme_dark_inverseOnSurface = Color(0xFF1F1B16) -val md_theme_dark_inverseSurface = Color(0xFFEAE1D9) -val md_theme_dark_inversePrimary = Color(0xFF825500) -val md_theme_dark_shadow = Color(0xFF000000) -val md_theme_dark_surfaceTint = Color(0xFFFFB951) -val md_theme_dark_outlineVariant = Color(0xFF4F4539) -val md_theme_dark_scrim = Color(0xFF000000) - - -val seed = Color(0xFF825500) - -val LightColors = lightColorScheme( - primary = md_theme_light_primary, - onPrimary = md_theme_light_onPrimary, - primaryContainer = md_theme_light_primaryContainer, - onPrimaryContainer = md_theme_light_onPrimaryContainer, - secondary = md_theme_light_secondary, - onSecondary = md_theme_light_onSecondary, - secondaryContainer = md_theme_light_secondaryContainer, - onSecondaryContainer = md_theme_light_onSecondaryContainer, - tertiary = md_theme_light_tertiary, - onTertiary = md_theme_light_onTertiary, - tertiaryContainer = md_theme_light_tertiaryContainer, - onTertiaryContainer = md_theme_light_onTertiaryContainer, - error = md_theme_light_error, - errorContainer = md_theme_light_errorContainer, - onError = md_theme_light_onError, - onErrorContainer = md_theme_light_onErrorContainer, - background = md_theme_light_background, - onBackground = md_theme_light_onBackground, - surface = md_theme_light_surface, - onSurface = md_theme_light_onSurface, - surfaceVariant = md_theme_light_surfaceVariant, - onSurfaceVariant = md_theme_light_onSurfaceVariant, - outline = md_theme_light_outline, - inverseOnSurface = md_theme_light_inverseOnSurface, - inverseSurface = md_theme_light_inverseSurface, - inversePrimary = md_theme_light_inversePrimary, - surfaceTint = md_theme_light_surfaceTint, - outlineVariant = md_theme_light_outlineVariant, - scrim = md_theme_light_scrim, -) - -val DarkColors = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - primaryContainer = md_theme_dark_primaryContainer, - onPrimaryContainer = md_theme_dark_onPrimaryContainer, - secondary = md_theme_dark_secondary, - onSecondary = md_theme_dark_onSecondary, - secondaryContainer = md_theme_dark_secondaryContainer, - onSecondaryContainer = md_theme_dark_onSecondaryContainer, - tertiary = md_theme_dark_tertiary, - onTertiary = md_theme_dark_onTertiary, - tertiaryContainer = md_theme_dark_tertiaryContainer, - onTertiaryContainer = md_theme_dark_onTertiaryContainer, - error = md_theme_dark_error, - errorContainer = md_theme_dark_errorContainer, - onError = md_theme_dark_onError, - onErrorContainer = md_theme_dark_onErrorContainer, - background = md_theme_dark_background, - onBackground = md_theme_dark_onBackground, - surface = md_theme_dark_surface, - onSurface = md_theme_dark_onSurface, - surfaceVariant = md_theme_dark_surfaceVariant, - onSurfaceVariant = md_theme_dark_onSurfaceVariant, - outline = md_theme_dark_outline, - inverseOnSurface = md_theme_dark_inverseOnSurface, - inverseSurface = md_theme_dark_inverseSurface, - inversePrimary = md_theme_dark_inversePrimary, - surfaceTint = md_theme_dark_surfaceTint, - outlineVariant = md_theme_dark_outlineVariant, - scrim = md_theme_dark_scrim, -) +private val LightColors = lightColorScheme() +private val DarkColors = darkColorScheme() +//private val LightColors = dynamicLightColorScheme() +//private val DarkColors = dynamicDarkColorScheme() @Composable fun CustomTheme(context: Context, content: @Composable () -> Unit) { -// val primaryColor = getPrimaryColor(context) -// val secondaryColor = getSecondaryColor(context) - val colors = when (readThemeValue(context)) { ThemePreference.LIGHT -> { Logd(TAG, "Light theme") LightColors } - ThemePreference.DARK, ThemePreference.BLACK -> { + ThemePreference.DARK -> { Logd(TAG, "Dark theme") DarkColors } + ThemePreference.BLACK -> { + Logd(TAG, "Dark theme") + DarkColors.copy(surface = Color(0xFF000000)) + } ThemePreference.SYSTEM -> { if (isSystemInDarkTheme()) { Logd(TAG, "System Dark theme") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt index ce963395..1d2c693a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt @@ -2,9 +2,12 @@ package ac.mdiq.podcini.ui.compose import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -68,4 +71,34 @@ fun CustomToast(message: String, durationMillis: Long = 2000L, onDismiss: () -> Text(text = message, color = Color.White, style = MaterialTheme.typography.bodyMedium) } } +} + +@Composable +fun AutoCompleteTextView(suggestions: List, onItemSelected: (String) -> Unit, modifier: Modifier = Modifier) { + var text by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + TextField(value = text, + onValueChange = { text = it }, + label = { Text("Search") }, + trailingIcon = { + IconButton(onClick = { expanded = !expanded }) { + Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "Expand") + } + } + ) + + if (expanded) { + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + suggestions.forEach { suggestion -> + DropdownMenuItem(text = {Text(suggestion)}, onClick = { + onItemSelected(suggestion) + text = suggestion + expanded = false + }) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index 0e46663a..3fdcd122 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -100,7 +100,7 @@ class EpisodeVM(var episode: Episode) { var positionState by mutableStateOf(episode.media?.position?:0) var playedState by mutableStateOf(episode.isPlayed()) var isPlayingState by mutableStateOf(false) - var farvoriteState by mutableStateOf(episode.isFavorite) + var ratingState by mutableIntStateOf(episode.rating) var inProgressState by mutableStateOf(episode.isInProgress) var downloadState by mutableIntStateOf(if (episode.media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal) var isRemote by mutableStateOf(false) @@ -112,8 +112,8 @@ class EpisodeVM(var episode: Episode) { var isSelected by mutableStateOf(false) var prog by mutableFloatStateOf(0f) - var episodeMonitor: Job? by mutableStateOf(null) - var mediaMonitor: Job? by mutableStateOf(null) + private var episodeMonitor: Job? by mutableStateOf(null) + private var mediaMonitor: Job? by mutableStateOf(null) fun stopMonitoring() { episodeMonitor?.cancel() @@ -136,7 +136,7 @@ class EpisodeVM(var episode: Episode) { if (episode.id == changes.obj.id) { withContext(Dispatchers.Main) { playedState = changes.obj.isPlayed() - farvoriteState = changes.obj.isFavorite + ratingState = changes.obj.rating episode = changes.obj // direct assignment doesn't update member like media?? } Logd("EpisodeVM", "episodeMonitor $playedState $playedState ") @@ -193,7 +193,28 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, }) @Composable - fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList, modifier: Modifier = Modifier) { + fun ChooseRatingDialog(onDismissRequest: () -> Unit) { + Dialog(onDismissRequest = onDismissRequest) { + Surface(shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + for (rating in Episode.Rating.entries) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { + for (item in selected) Episodes.setRating(item, rating.code) + onDismissRequest() + }) { + Icon(imageVector = ImageVector.vectorResource(id = rating.res), "") + Text(rating.name, Modifier.padding(start = 4.dp)) + } + } + } + } + } + } + var showChooseRatingDialog by remember { mutableStateOf(false) } + if (showChooseRatingDialog) ChooseRatingDialog { showChooseRatingDialog = false } + + @Composable + fun EpisodeSpeedDial(modifier: Modifier = Modifier) { var isExpanded by remember { mutableStateOf(false) } val options = mutableListOf<@Composable () -> Unit>( { Row(modifier = Modifier.padding(horizontal = 16.dp) @@ -202,11 +223,9 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, selectMode = false Logd(TAG, "ic_delete: ${selected.size}") LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected) - }, verticalAlignment = Alignment.CenterVertically - ) { + }, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "") - Text(stringResource(id = R.string.delete_episode_label)) - } }, + Text(stringResource(id = R.string.delete_episode_label)) } }, { Row(modifier = Modifier.padding(horizontal = 16.dp) .clickable { isExpanded = false @@ -216,94 +235,81 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, if (episode.media != null && episode.feed != null && !episode.feed!!.isLocalFeed) DownloadServiceInterface.get() ?.download(activity, episode) } - }, verticalAlignment = Alignment.CenterVertically - ) { + }, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "") - Text(stringResource(id = R.string.download_label)) - } }, + Text(stringResource(id = R.string.download_label)) } }, { Row(modifier = Modifier.padding(horizontal = 16.dp) .clickable { isExpanded = false selectMode = false Logd(TAG, "ic_mark_played: ${selected.size}") setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray()) - }, verticalAlignment = Alignment.CenterVertically - ) { + }, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "") - Text(stringResource(id = R.string.toggle_played_label)) - } }, + Text(stringResource(id = R.string.toggle_played_label)) } }, { Row(modifier = Modifier.padding(horizontal = 16.dp) .clickable { isExpanded = false selectMode = false Logd(TAG, "ic_playlist_remove: ${selected.size}") removeFromQueue(*selected.toTypedArray()) - }, verticalAlignment = Alignment.CenterVertically - ) { + }, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "") - Text(stringResource(id = R.string.remove_from_queue_label)) - } }, + Text(stringResource(id = R.string.remove_from_queue_label)) } }, { Row(modifier = Modifier.padding(horizontal = 16.dp) .clickable { isExpanded = false selectMode = false Logd(TAG, "ic_playlist_play: ${selected.size}") Queues.addToQueue(true, *selected.toTypedArray()) - }, verticalAlignment = Alignment.CenterVertically - ) { + }, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") - Text(stringResource(id = R.string.add_to_queue_label)) - } }, + Text(stringResource(id = R.string.add_to_queue_label)) } }, { Row(modifier = Modifier.padding(horizontal = 16.dp) .clickable { isExpanded = false selectMode = false Logd(TAG, "ic_playlist_play: ${selected.size}") PutToQueueDialog(activity, selected).show() - }, verticalAlignment = Alignment.CenterVertically - ) { + }, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") - Text(stringResource(id = R.string.put_in_queue_label)) - } }, + Text(stringResource(id = R.string.put_in_queue_label)) } }, { Row(modifier = Modifier.padding(horizontal = 16.dp) .clickable { - isExpanded = false selectMode = false Logd(TAG, "ic_star: ${selected.size}") - for (item in selected) { - Episodes.setFavorite(item, null) - } - }, verticalAlignment = Alignment.CenterVertically - ) { + showChooseRatingDialog = true + isExpanded = false + }, verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "") - Text(stringResource(id = R.string.toggle_favorite_label)) - } }, + Text(stringResource(id = R.string.set_rating_label)) } }, ) if (selected.isNotEmpty() && selected[0].isRemote.value) - options.add({ Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "reserve: ${selected.size}") - CoroutineScope(Dispatchers.IO).launch { - youtubeUrls.clear() - for (e in selected) { - Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}") - val url = URL(e.media?.downloadUrl?: "") - if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) { - youtubeUrls.add(e.media!!.downloadUrl!!) - } else addToMiscSyndicate(e) + options.add { + Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + selectMode = false + Logd(TAG, "reserve: ${selected.size}") + CoroutineScope(Dispatchers.IO).launch { + youtubeUrls.clear() + for (e in selected) { + Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}") + val url = URL(e.media?.downloadUrl ?: "") + if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) { + youtubeUrls.add(e.media!!.downloadUrl!!) + } else addToMiscSyndicate(e) + } + Logd(TAG, "youtubeUrls: ${youtubeUrls.size}") + withContext(Dispatchers.Main) { + showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty() + } } - Logd(TAG, "youtubeUrls: ${youtubeUrls.size}") - withContext(Dispatchers.Main) { - showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty() - } - } - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Filled.AddCircle, "") - Text(stringResource(id = R.string.reserve_episodes_label)) - } }) + }, verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.AddCircle, "") + Text(stringResource(id = R.string.reserve_episodes_label)) + } + } val scrollState = rememberScrollState() Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) { @@ -312,7 +318,8 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, containerColor = Color.LightGray, onClick = {}) { button() } } - FloatingActionButton(containerColor = Color.Green, + FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.secondary, onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } } } @@ -437,8 +444,9 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, Row { if (vm.episode.media?.getMediaType() == MediaType.VIDEO) Icon(painter = painterResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(14.dp).height(14.dp)) - if (vm.farvoriteState) - Icon(painter = painterResource(R.drawable.ic_star), tint = textColor, contentDescription = "isFavorite", modifier = Modifier.width(14.dp).height(14.dp)) + val ratingIconRes = Episode.Rating.fromCode(vm.ratingState).res + if (vm.ratingState != Episode.Rating.NEUTRAL.code) + Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.surfaceTint, contentDescription = "rating", modifier = Modifier.width(14.dp).height(14.dp)) if (vm.inQueueState) Icon(painter = painterResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp)) val curContext = LocalContext.current @@ -532,7 +540,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, Logd(TAG, "selectedIds: ${selected.size}") })) } - EpisodeSpeedDial(activity, selected.toMutableStateList(), modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp)) + EpisodeSpeedDial(modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp)) } } } @@ -546,7 +554,12 @@ fun confirmAddYoutubeEpisode(sharedUrls: List, showDialog: Boolean, onDi if (showDialog) { Dialog(onDismissRequest = { onDismissRequest() }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), ) { + Card( + modifier = Modifier + .wrapContentSize(align = Alignment.Center) + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) { var audioOnly by remember { mutableStateOf(false) } Row(Modifier.fillMaxWidth()) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/AuthenticationDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/AuthenticationDialog.kt deleted file mode 100644 index 9a5b0972..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/AuthenticationDialog.kt +++ /dev/null @@ -1,50 +0,0 @@ -package ac.mdiq.podcini.ui.dialog - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.AuthenticationDialogBinding -import android.content.Context -import android.content.DialogInterface -import android.text.method.HideReturnsTransformationMethod -import android.text.method.PasswordTransformationMethod -import android.view.LayoutInflater -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -/** - * Displays a dialog with a username and password text field and an optional checkbox to save username and preferences. - */ -abstract class AuthenticationDialog(context: Context, titleRes: Int, enableUsernameField: Boolean, usernameInitialValue: String?, passwordInitialValue: String?) - : MaterialAlertDialogBuilder(context) { - - var passwordHidden: Boolean = true - - init { - setTitle(titleRes) - val viewBinding = AuthenticationDialogBinding.inflate(LayoutInflater.from(context)) - setView(viewBinding.root) - - viewBinding.usernameEditText.isEnabled = enableUsernameField - if (usernameInitialValue != null) viewBinding.usernameEditText.setText(usernameInitialValue) - if (passwordInitialValue != null) viewBinding.passwordEditText.setText(passwordInitialValue) - - viewBinding.showPasswordButton.setOnClickListener { - if (passwordHidden) { - viewBinding.passwordEditText.transformationMethod = HideReturnsTransformationMethod.getInstance() - viewBinding.showPasswordButton.alpha = 1.0f - } else { - viewBinding.passwordEditText.transformationMethod = PasswordTransformationMethod.getInstance() - viewBinding.showPasswordButton.alpha = 0.6f - } - passwordHidden = !passwordHidden - } - - setOnCancelListener { onCancelled() } - setNegativeButton(R.string.cancel_label) { _: DialogInterface?, _: Int -> onCancelled() } - setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - onConfirmed(viewBinding.usernameEditText.text.toString(), viewBinding.passwordEditText.text.toString()) - } - } - - protected open fun onCancelled() {} - - protected abstract fun onConfirmed(username: String, password: String) -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index ff43a887..cca0f4be 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -899,7 +899,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { onPlaybackServiceChanged(event) } is FlowEvent.PlayEvent -> onPlayEvent(event) - is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) + is FlowEvent.RatingEvent -> onFavoriteEvent(event) is FlowEvent.PlayerErrorEvent -> MediaPlayerErrorDialog.show(activity as Activity, event) // is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) loadMediaInfo(false) is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) setupOptionsMenu() @@ -911,7 +911,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } - private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) { + private fun onFavoriteEvent(event: FlowEvent.RatingEvent) { if (curEpisode?.id == event.episode.id) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, event.episode) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt index b6d32c7c..0728eecb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt @@ -189,7 +189,7 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene Logd(TAG, "Received event: ${event.TAG}") when (event) { is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() - is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent, is FlowEvent.FavoritesEvent -> loadItems() + is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent, is FlowEvent.RatingEvent -> loadItems() else -> {} } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt index e0e36e60..3ff4414f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -21,7 +21,6 @@ import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.ui.actions.* -import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.utils.ShownotesCleaner @@ -46,24 +45,27 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll -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.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.app.ShareCompat import androidx.core.text.HtmlCompat import androidx.core.view.MenuProvider @@ -78,11 +80,8 @@ import com.skydoves.balloon.ArrowOrientation import com.skydoves.balloon.ArrowOrientationRules import com.skydoves.balloon.Balloon import com.skydoves.balloon.BalloonAnimation -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.dankito.readability4j.extended.Readability4JExtended import okhttp3.Request.Builder import java.io.File @@ -123,19 +122,16 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { toolbar = binding.toolbar toolbar.title = "" - toolbar.inflateMenu(R.menu.feeditem_options) + toolbar.inflateMenu(R.menu.episode_info) toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } toolbar.setOnMenuItemClickListener(this) binding.composeView.setContent{ CustomTheme(requireContext()) { - InfoView() + MainView() } } -// binding.txtvPodcast.setOnClickListener { openPodcast() } -// binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL) -// binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END // webvDescription = binding.webvDescription // webvDescription.setTimecodeSelectedListener { time: Int? -> // val cMedia = curMedia @@ -144,40 +140,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { // } // registerForContextMenu(webvDescription) -// imgvCover = binding.imgvCover -// imgvCover.setOnClickListener { openPodcast() } -// butAction1 = binding.butAction1 -// butAction2 = binding.butAction2 - -// binding.homeButton.setOnClickListener { -// if (!episode?.link.isNullOrEmpty()) { -// homeFragment = EpisodeHomeFragment.newInstance(episode!!) -// (activity as MainActivity).loadChildFragment(homeFragment!!) -// } else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show() -// } - -// butAction1.setOnClickListener(View.OnClickListener { -// when { -// actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload -// && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> { -// showOnDemandConfigBalloon(true) -// return@OnClickListener -// } -// actionButton1 == null -> return@OnClickListener // Not loaded yet -// else -> actionButton1?.onClick(requireContext()) -// } -// }) -// butAction2.setOnClickListener(View.OnClickListener { -// when { -// actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload -// && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> { -// showOnDemandConfigBalloon(false) -// return@OnClickListener -// } -// actionButton2 == null -> return@OnClickListener // Not loaded yet -// else -> actionButton2?.onClick(requireContext()) -// } -// }) shownotesCleaner = ShownotesCleaner(requireContext()) onFragmentLoaded() load() @@ -185,16 +147,55 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } @Composable - fun InfoView() { + fun MainView() { + val textColor = MaterialTheme.colorScheme.onSurface + var showEditComment by remember { mutableStateOf(false) } + @Composable + fun LargeTextEditingDialog(textState: TextFieldValue, onTextChange: (TextFieldValue) -> Unit, onDismissRequest: () -> Unit, onSave: (String) -> Unit) { + Dialog(onDismissRequest = { onDismissRequest() }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = MaterialTheme.shapes.medium) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = "Add comment", color = textColor, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(16.dp)) + BasicTextField(value = textState, onValueChange = { onTextChange(it) }, textStyle = TextStyle(fontSize = 16.sp, color = textColor), + modifier = Modifier.fillMaxWidth().height(300.dp).padding(10.dp).border(1.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = { onDismissRequest() }) { + Text("Cancel") + } + TextButton(onClick = { + onSave(textState.text) + onDismissRequest() + }) { + Text("Save") + } + } + } + } + LaunchedEffect(Unit) { + while (true) { + delay(10000) + onSave(textState.text) + } + } + } + } + var commentTextState by remember { mutableStateOf(TextFieldValue(episode?.comment?:"")) } + if (showEditComment) LargeTextEditingDialog(textState = commentTextState, onTextChange = { commentTextState = it }, onDismissRequest = {showEditComment = false}, + onSave = { + runOnIOScope { if (episode != null) episode = upsert(episode!!) { it.comment = commentTextState.text } } + }) + Column { - val textColor = MaterialTheme.colorScheme.onSurface Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) { val imgLoc = if (episode != null) ImageResourceUtils.getEpisodeListImageLocation(episode!!) else null AsyncImage(model = imgLoc, contentDescription = "imgvCover", Modifier.width(56.dp).height(56.dp).clickable(onClick = { openPodcast() })) Column(modifier = Modifier.padding(start = 10.dp)) { Text(txtvPodcast, color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.clickable { openPodcast() }) Text(txtvTitle, color = textColor, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), maxLines = 5, overflow = TextOverflow.Ellipsis) - Text(txtvPublished + " · " + txtvDuration + " · " + txtvSize, color = textColor, style = MaterialTheme.typography.bodyMedium) + Text("$txtvPublished · $txtvDuration · $txtvSize", color = textColor, style = MaterialTheme.typography.bodyMedium) } } Row(verticalAlignment = Alignment.CenterVertically) { @@ -250,6 +251,10 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { }, update = { it.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") }) + Text(stringResource(R.string.my_opinion_label) + if (commentTextState.text.isEmpty()) " (Add)" else "", + color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 15.dp, top = 10.dp, bottom = 5.dp).clickable { showEditComment = true }) + Text(commentTextState.text, color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 15.dp, bottom = 10.dp)) Text(itemLink, color = textColor, style = MaterialTheme.typography.bodySmall) } } @@ -339,7 +344,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { @OptIn(UnstableApi::class) override fun onDestroyView() { Logd(TAG, "onDestroyView") -// binding.root.removeView(webvDescription) episode = null // webvDescription.clearHistory() // webvDescription.clearCache(true) @@ -432,11 +436,11 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { val dls = DownloadServiceInterface.get() if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) { val url = episode!!.media!!.downloadUrl!! - if (dls != null && dls.isDownloadingEpisode(url)) { +// if (dls != null && dls.isDownloadingEpisode(url)) { // binding.circularProgressBar.visibility = View.VISIBLE // binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode) // binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) - } +// } } val media: EpisodeMedia? = episode?.media @@ -470,15 +474,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { // if (actionButton2 != null && media.getMediaType() == MediaType.FLASH) actionButton2!!.visibility = View.GONE } } - - if (actionButton1 != null) { -// butAction1.setImageResource(actionButton1!!.getDrawable()) -// butAction1.visibility = actionButton1!!.visibility - } - if (actionButton2 != null) { -// butAction2.setImageResource(actionButton2!!.getDrawable()) -// butAction2.visibility = actionButton2!!.visibility - } } // override fun onContextItemSelected(item: MenuItem): Boolean { @@ -506,7 +501,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { Logd(TAG, "Received event: ${event.TAG}") when (event) { is FlowEvent.QueueEvent -> onQueueEvent(event) - is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) + is FlowEvent.RatingEvent -> onFavoriteEvent(event) is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) is FlowEvent.PlayerSettingsEvent -> updateButtons() is FlowEvent.EpisodePlayedEvent -> load() @@ -525,10 +520,10 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } - private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) { + private fun onFavoriteEvent(event: FlowEvent.RatingEvent) { if (episode?.id == event.episode.id) { episode = unmanaged(episode!!) - episode!!.isFavorite = event.episode.isFavorite + episode!!.rating = if (event.episode.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code // episode = event.episode prepareMenu() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index 9aeb6787..096dd678 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -359,6 +359,13 @@ import java.util.concurrent.Semaphore }.start() } R.id.sort_items -> SingleFeedSortDialog(feed).show(childFragmentManager, "SortDialog") +// R.id.filter_items -> {} +// R.id.settings -> { +// if (feed != null) { +// val fragment = FeedSettingsFragment.newInstance(feed!!) +// (activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE) +// } +// } R.id.rename_feed -> CustomFeedNameDialog(activity as Activity, feed!!).show() R.id.remove_feed -> { RemoveFeedDialog.show(requireContext(), feed!!) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index c1f9bb68..7be6c7fb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -54,11 +54,12 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddCircle import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.outlined.AddCircle +import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList @@ -802,7 +803,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { containerColor = Color.LightGray, onClick = {}) { button() } } - FloatingActionButton(containerColor = Color.Green, + FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.secondary, onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } } } @@ -1014,11 +1016,13 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { } EpisodeSpeedDial(activity as MainActivity, selected.toMutableStateList(), modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp)) } - FloatingActionButton(containerColor = Color.Green, + FloatingActionButton(shape = CircleShape, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.secondary, modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 16.dp, end = 16.dp), onClick = { if (activity is MainActivity) (activity as MainActivity).loadChildFragment(OnlineSearchFragment()) - }) { Icon(Icons.Filled.AddCircle, "Add") } + }) { Icon(Icons.Outlined.AddCircle, "Add", modifier = Modifier.size(60.dp)) } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt index 679fccac..92c26810 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt @@ -168,7 +168,7 @@ sealed class FlowEvent { // TODO: need better handling at receving end data class EpisodePlayedEvent(val episode: Episode? = null) : FlowEvent() - data class FavoritesEvent(val episode: Episode) : FlowEvent() + data class RatingEvent(val episode: Episode, val rating: Int = Episode.Rating.FAVORITE.code) : FlowEvent() data class AllEpisodesFilterEvent(val filterValues: Set?) : FlowEvent() diff --git a/app/src/main/res/menu/feeditem_options.xml b/app/src/main/res/menu/episode_info.xml similarity index 100% rename from app/src/main/res/menu/feeditem_options.xml rename to app/src/main/res/menu/episode_info.xml diff --git a/app/src/main/res/menu/feed_episodes.xml b/app/src/main/res/menu/feed_episodes.xml index 9b675b69..d916899b 100644 --- a/app/src/main/res/menu/feed_episodes.xml +++ b/app/src/main/res/menu/feed_episodes.xml @@ -11,10 +11,24 @@ + + + + + + + + + + + + + + + Your subscriptions have new episodes. - Please confirm that you want to perform the action on all selected items. Please confirm that you want to toggle played status of all selected items. Please confirm that you want to mark all selected items as played. @@ -251,8 +250,8 @@ JavaScript - No action + My opinion Removed from inbox Mark as played @@ -287,7 +286,9 @@ Play video Add to favorites + Swtich rating Toggle favorites + Set rating Remove from favorites Visit website Skip episode diff --git a/app/src/test/kotlin/ac/mdiq/podcini/feed/LocalFeedUpdaterTest.kt b/app/src/test/kotlin/ac/mdiq/podcini/feed/LocalFeedUpdaterTest.kt index e2cd19d3..5bf04dd3 100644 --- a/app/src/test/kotlin/ac/mdiq/podcini/feed/LocalFeedUpdaterTest.kt +++ b/app/src/test/kotlin/ac/mdiq/podcini/feed/LocalFeedUpdaterTest.kt @@ -5,11 +5,10 @@ import ac.mdiq.podcini.net.feed.LocalFeedUpdater.getImageUrl import ac.mdiq.podcini.net.feed.LocalFeedUpdater.tryUpdateFeed import ac.mdiq.podcini.net.download.service.DownloadServiceInterface import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterfaceTestStub +import ac.mdiq.podcini.net.feed.LocalFeedUpdater.FastDocumentFile.Companion.list import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.utils.FastDocumentFile -import ac.mdiq.podcini.storage.utils.FastDocumentFile.Companion.list import ac.mdiq.podcini.util.config.ApplicationCallbacks import ac.mdiq.podcini.util.config.ClientConfig import android.app.Application @@ -233,7 +232,7 @@ class LocalFeedUpdaterTest { * @param localFeedDir assets local feed folder with media files */ private fun callUpdateFeed(localFeedDir: String) { - Mockito.mockStatic(FastDocumentFile::class.java).use { dfMock -> + Mockito.mockStatic(LocalFeedUpdater.FastDocumentFile::class.java).use { dfMock -> // mock external storage dfMock.`when` { list(ArgumentMatchers.any(), ArgumentMatchers.any()) } .thenReturn(mockLocalFolder(localFeedDir)) @@ -283,16 +282,16 @@ class LocalFeedUpdaterTest { /** * Create a DocumentFile mock object. */ - private fun mockDocumentFile(fileName: String, mimeType: String): FastDocumentFile { - return FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/$fileName"), 0, 0) + private fun mockDocumentFile(fileName: String, mimeType: String): LocalFeedUpdater.FastDocumentFile { + return LocalFeedUpdater.FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/$fileName"), 0, 0) } - private fun mockLocalFolder(folderName: String): List { - val files: MutableList = ArrayList() + private fun mockLocalFolder(folderName: String): List { + val files: MutableList = ArrayList() for (f in Objects.requireNonNull>(File(folderName).listFiles())) { val extension = MimeTypeMap.getFileExtensionFromUrl(f.path) val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - files.add(FastDocumentFile(f.name, mimeType!!, + files.add(LocalFeedUpdater.FastDocumentFile(f.name, mimeType!!, Uri.parse(f.toURI().toString()), f.length(), f.lastModified())) } return files diff --git a/changelog.md b/changelog.md index be0ac22d..8e737aa8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,14 @@ +# 6.9.2 + +* fixed getting 0 episodes with Youtube playlist etc +* added new ratings for episodes: Trash, Bad, Neutral, Good, Favorite +* previous Favorite is migrated to the new ratings +* "Add to favorite" SwipeActions is changed to "Switch rating" SwipeActions, each swipe shifts the rating in circle +* "Add to favorite" in multi-selection is changed to "Set rating" +* in EpisodeInfo, added "My opinion" section under the Description text, + * by clicking on it you can add personal comments/notes on the episode, text entered is auto-saved every 10 seconds +* adopted Material3's built-in color scheme + # 6.9.1 * added logging for shared actions diff --git a/fastlane/metadata/android/en-US/changelogs/3020266.txt b/fastlane/metadata/android/en-US/changelogs/3020266.txt index 6c4f3f3b..a195e4e6 100644 --- a/fastlane/metadata/android/en-US/changelogs/3020266.txt +++ b/fastlane/metadata/android/en-US/changelogs/3020266.txt @@ -1,4 +1,4 @@ - Version 6.9.0 + Version 6.9.1 * added logging for shared actions * added simple fragment for viewing shared logs and repairing failed share actions diff --git a/fastlane/metadata/android/en-US/changelogs/3020267.txt b/fastlane/metadata/android/en-US/changelogs/3020267.txt new file mode 100644 index 00000000..f5458cd5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020267.txt @@ -0,0 +1,10 @@ + Version 6.9.2 + +* fixed getting 0 episodes with Youtube playlist etc +* added new ratings for episodes: Trash, Bad, Neutral, Good, Favorite +* previous Favorite is migrated to the new ratings +* "Add to favorite" SwipeActions is changed to "Switch rating" SwipeActions, each swipe shifts the rating in circle +* "Add to favorite" in multi-selection is changed to "Set rating" +* in EpisodeInfo, added "My opinion" section under the Description text, + * by clicking on it you can add personal comments/notes on the episode, text entered is auto-saved every 10 seconds +* adopted Material3's built-in color scheme diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 16c9ecca..42db934f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ lifecycleRuntimeKtx = "2.8.6" #material = "1.7.2" material3 = "1.3.0" #material3Android = "1.3.0" +materialIconsExtended = "1.7.3" materialVersion = "1.12.0" media3Common = "1.4.1" media3Session = "1.4.1" @@ -92,6 +93,7 @@ androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } #androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } androidx-material3-android = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }