diff --git a/app/build.gradle b/app/build.gradle index 83b1403a..c31b615e 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 3020269 - versionName "6.10.0" + versionCode 3020270 + versionName "6.11.0" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt index 2347b04b..5c3a1960 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloadServiceInterfaceImpl.kt @@ -231,10 +231,10 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() { val retrying = !isLastRunAttempt && !isImmediateFail if (episodeTitle.length > 20) episodeTitle = episodeTitle.substring(0, 19) + "…" - EventFlow.postEvent(FlowEvent.MessageEvent(applicationContext.getString( - if (retrying) R.string.download_error_retrying else R.string.download_error_not_retrying, - episodeTitle), { ctx: Context -> MainActivityStarter(ctx).withDownloadLogsOpen().start() }, applicationContext.getString( - R.string.download_error_details))) + EventFlow.postEvent(FlowEvent.MessageEvent( + applicationContext.getString(if (retrying) R.string.download_error_retrying else R.string.download_error_not_retrying, episodeTitle), + { ctx: Context -> MainActivityStarter(ctx).withDownloadLogsOpen().start() }, + applicationContext.getString(R.string.download_error_details))) } private fun getDownloadLogsIntent(context: Context): PendingIntent { val intent = MainActivityStarter(context).withDownloadLogsOpen().getIntent() 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 babf8c5d..969564c1 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 @@ -24,7 +24,6 @@ import ac.mdiq.vista.extractor.stream.StreamInfoItem import android.content.Context import android.util.Log import io.realm.kotlin.ext.realmListOf -import io.realm.kotlin.types.RealmList import kotlinx.coroutines.* import org.jsoup.Jsoup import java.io.File @@ -276,6 +275,10 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) media?.episode = item } val fo = updateFeed(context, feed, false) +// if (fo?.downloadUrl != null || fo?.link != null) { +// val fLog = SubscriptionLog(fo.id, fo.title?:"", fo.downloadUrl?:"", fo.link?:"", SubscriptionLog.Type.Feed.name) +// upsertBlk(fLog) {} +// } Logd(TAG, "fo.id: ${fo?.id} feed.id: ${feed.id}") } } \ No newline at end of file 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 57198f42..f077d4f2 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 @@ -14,6 +14,7 @@ import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeFilter import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded import ac.mdiq.podcini.storage.model.EpisodeSortOrder +import ac.mdiq.podcini.storage.model.Rating import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent @@ -333,7 +334,7 @@ import kotlin.math.min it.media!!.setPosition(action.position * 1000) it.media!!.playedDuration = action.playedDuration * 1000 it.media!!.setLastPlayedTime(action.timestamp!!.time) - it.rating = if (action.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code + it.rating = if (action.isFavorite) Rating.FAVORITE.code else Rating.UNRATED.code it.playState = action.playState if (hasAlmostEnded(it.media!!)) { Logd(TAG, "Marking as played") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/utils/UrlChecker.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/utils/UrlChecker.kt index 5574ad6e..60602425 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/utils/UrlChecker.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/utils/UrlChecker.kt @@ -23,7 +23,7 @@ object UrlChecker { var url = url_ url = url.trim { it <= ' ' } val lowerCaseUrl = url.lowercase() // protocol names are case insensitive - Logd(TAG, "prepareUrl lowerCaseUrl: $lowerCaseUrl") +// Logd(TAG, "prepareUrl lowerCaseUrl: $lowerCaseUrl") return when { lowerCaseUrl.startsWith("feed://") -> prepareUrl(url.substring("feed://".length)) lowerCaseUrl.startsWith("pcast://") -> prepareUrl(url.substring("pcast://".length)) 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 4c81270c..ec5f8d52 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 @@ -14,10 +14,7 @@ import ac.mdiq.podcini.storage.database.Episodes.getEpisodes import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeFilter -import ac.mdiq.podcini.storage.model.EpisodeSortOrder -import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName import ac.mdiq.podcini.storage.utils.FilesUtils.getDataFolder @@ -35,7 +32,6 @@ 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 @@ -942,7 +938,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() { it.media!!.setPosition(action.position * 1000) it.media!!.playedDuration = action.playedDuration * 1000 it.media!!.setLastPlayedTime(action.timestamp!!.time) - it.rating = if (action.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code + it.rating = if (action.isFavorite) Rating.FAVORITE.code else Rating.UNRATED.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/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt index a0fb8874..d3c37b28 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -332,6 +332,16 @@ object Feeds { } } + fun setRating(feed: Feed, rating: Int) { + Logd(TAG, "setRating called $rating") +// return runOnIOScope { + val result = upsertBlk(feed) { it.rating = rating } +// val slog = realm.query(SubscriptionLog::class).query("itemId == $0", feed.id).first().find() +// if (slog != null) upsertBlk(slog) { it.rating = rating } +// EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating)) +// } + } + fun updateFeedDownloadURL(original: String, updated: String) : Job { Logd(TAG, "updateFeedDownloadURL(original: $original, updated: $updated)") return runOnIOScope { 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 3e72d8bc..b61c2c33 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 @@ -14,10 +14,13 @@ 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.BaseRealmObject +import io.realm.kotlin.types.RealmList import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.TypedRealmObject import kotlinx.coroutines.* import kotlin.coroutines.ContinuationInterceptor +import kotlin.reflect.KClass object RealmDB { private val TAG: String = RealmDB::class.simpleName ?: "Anonymous" @@ -38,13 +41,15 @@ object RealmDB { PlayQueue::class, DownloadResult::class, ShareLog::class, + SubscriptionLog::class, Chapter::class)) .name("Podcini.realm") - .schemaVersion(25) + .schemaVersion(26) .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) { + Logd(TAG, "migrating DB from below 25") mContext.enumerate(className = "Episode") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? -> newObject?.run { set( @@ -54,6 +59,55 @@ object RealmDB { } } } + if (oldRealm.schemaVersion() < 26) { + Logd(TAG, "migrating DB from below 26") + mContext.enumerate(className = "Episode") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? -> + newObject?.run { + if (oldObject.getValue(fieldName = "rating") == 0L) set("rating", -3L) + } + } +// val feeds = oldRealm.query(className = "Feed").query("id > 10000").find() +// for (f in feeds) { +// val id = f.getValue(propertyName = "id", Long::class) +// val url = f.getNullableValue(propertyName = "downloadUrl", String::class) +// val link = f.getNullableValue(propertyName = "link", String::class) +// val title = f.getNullableValue(propertyName = "eigenTitle", String::class) +// val subLog = newRealm.copyToRealm( +// DynamicMutableRealmObject.create( +// type = "SubscriptionLog", +// mapOf( +// "id" to id/100, +// "itemId" to id, +// "url" to url, +// "link" to link, +// "type" to "Feed", +// "title" to title, +// ) +// ) +// ) +// } +// val episodes = oldRealm.query(className = "Episode").query("feedId < 100").find() +// for (e in episodes) { +// val id = e.getValue(propertyName = "id", Long::class) +// val media = oldRealm.query(className = "EpisodeMedia").query("id == $id").first().find() +// val url = media?.getNullableValue(propertyName = "downloadUrl", String::class) ?:"" +// val link = e.getNullableValue(propertyName = "link", String::class) +// val title = e.getNullableValue(propertyName = "title", String::class) +// val subLog = newRealm.copyToRealm( +// DynamicMutableRealmObject.create( +// type = "SubscriptionLog", +// mapOf( +// "id" to id/100, +// "itemId" to id, +// "url" to url, +// "link" to link, +// "type" to "Media", +// "title" to title, +// ) +// ) +// ) +// } + } }) .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 c3c3bc6b..58610ad2 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,6 +1,5 @@ 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 @@ -84,7 +83,7 @@ class Episode : RealmObject { */ var chapters: RealmList = realmListOf() - var rating: Int = Rating.NEUTRAL.code + var rating: Int = Rating.UNRATED.code @Ignore var isFavorite: Boolean = (rating == 2) @@ -320,20 +319,6 @@ class Episode : RealmObject { 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), diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt index 703ab3ea..510e97af 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt @@ -94,6 +94,10 @@ class Feed : RealmObject { var hasVideoMedia: Boolean = false + var rating: Int = Rating.NEUTRAL.code + + var comment: String = "" + /** * Returns the value that uniquely identifies this Feed. If the * feedIdentifier attribute is not null, it will be returned. Else it will @@ -182,30 +186,32 @@ class Feed : RealmObject { /** * This constructor is used for requesting a feed download (it must not be used for anything else!). It should NOT be - * used if the title of the feed is already known. + * used if the title of the feed is already known. TODO: */ - constructor(url: String?, lastUpdate: String?) { + constructor(url: String?, lastUpdate: String?, title: String? = null, username: String? = null, password: String? = null) { this.lastUpdate = lastUpdate fileUrl = null this.downloadUrl = url - } - - /** - * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be - * used if the title of the feed is already known. - */ - constructor(url: String?, lastUpdate: String?, title: String?) : this(url, lastUpdate) { this.eigenTitle = title - } - - /** - * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be - * used if the title of the feed is already known. - */ - constructor(url: String?, lastUpdate: String?, title: String?, username: String?, password: String?) : this(url, lastUpdate, title) { preferences = FeedPreferences(0, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password) } +// /** +// * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be +// * used if the title of the feed is already known. +// */ +// constructor(url: String?, lastUpdate: String?, title: String?) : this(url, lastUpdate) { +// this.eigenTitle = title +// } +// +// /** +// * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be +// * used if the title of the feed is already known. +// */ +// constructor(url: String?, lastUpdate: String?, title: String?, username: String?, password: String?) : this(url, lastUpdate, title) { +// preferences = FeedPreferences(0, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password) +// } + fun setCustomTitle1(value: String?) { customTitle = if (value == null || value == eigenTitle) null else value } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Rating.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Rating.kt new file mode 100644 index 00000000..75962b2a --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Rating.kt @@ -0,0 +1,18 @@ +package ac.mdiq.podcini.storage.model + +import ac.mdiq.podcini.R + +enum class Rating(val code: Int, val res: Int) { + UNRATED(-3, R.drawable.ic_questionmark), + TRASH(-2, R.drawable.ic_delete), + BAD(-1, androidx.media3.session.R.drawable.media3_icon_thumb_down_filled), + NEUTRAL(0, R.drawable.baseline_sentiment_neutral_24), + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/SubscriptionLog.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/SubscriptionLog.kt new file mode 100644 index 00000000..b123b4c8 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/SubscriptionLog.kt @@ -0,0 +1,68 @@ +package ac.mdiq.podcini.storage.model + +import ac.mdiq.podcini.storage.database.RealmDB.realm +import ac.mdiq.podcini.util.Logd +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.PrimaryKey +import java.util.* + +class SubscriptionLog: RealmObject { + @PrimaryKey + var id: Long = 0L // this is the Date().time + + // this can be that of a feed or a synthetic episode +// var itemId: Long = 0L + + var url: String? = null + + var link: String? = null + + var title: String = "" + + var type: String? = null + + var cancelDate: Long = 0 + + var rating: Int = Rating.UNRATED.code + + var comment: String = "" + + constructor() {} + + constructor(itemId: Long, title: String, url: String, link: String, type: String) { +// this.itemId = itemId + this.title = title + this.url = url + this.link = link + this.type = type + + // itemId being either feed.id or episode.id is 100 times the creation time + id = itemId / 100 + } + + enum class Type { + Feed, + Media, + } + + companion object { + val TAG: String = SubscriptionLog::class.simpleName ?: "Anonymous" + + var feedLogsMap: Map? = null + get() { + if (field == null) field = getFeedLogMap() + return field + } + + fun getFeedLogMap(): Map { + val logs = realm.query(SubscriptionLog::class).query("type == $0", "Feed").find() + val map = mutableMapOf() + for (l in logs) { + Logd(TAG, "getFeedLogMap ${l.title} ${l.url}") + if (!l.url.isNullOrEmpty()) map[l.url!!] = l + if (!l.link.isNullOrEmpty()) map[l.link!!] = l + } + return map.toMap() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt index 4e2d2681..bc8dc597 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeAction.kt @@ -7,7 +7,6 @@ import androidx.fragment.app.Fragment import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeFilter - interface SwipeAction { fun getId(): String? fun getTitle(context: Context): String @@ -23,15 +22,15 @@ interface SwipeAction { fun willRemove(filter: EpisodeFilter, item: Episode): Boolean - companion object { - const val NO_ACTION: String = "NO_ACTION" - - const val ADD_TO_QUEUE: String = "ADD_TO_QUEUE" - const val START_DOWNLOAD: String = "START_DOWNLOAD" - const val MARK_FAV: String = "MARK_FAV" - const val TOGGLE_PLAYED: String = "MARK_PLAYED" - const val REMOVE_FROM_QUEUE: String = "REMOVE_FROM_QUEUE" - const val DELETE: String = "DELETE" - const val REMOVE_FROM_HISTORY: String = "REMOVE_FROM_HISTORY" + enum class ActionTypes { + NO_ACTION, + COMBO, + ADD_TO_QUEUE, + START_DOWNLOAD, + MARK_FAV, + TOGGLE_PLAYED, + REMOVE_FROM_QUEUE, + DELETE, + REMOVE_FROM_HISTORY } } 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 0f183ace..1333b9ed 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 @@ -5,7 +5,6 @@ import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync 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 @@ -18,8 +17,11 @@ import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeFilter import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.utils.EpisodeUtil -import ac.mdiq.podcini.ui.actions.SwipeAction.Companion.NO_ACTION +import ac.mdiq.podcini.ui.actions.SwipeAction.ActionTypes.NO_ACTION +import ac.mdiq.podcini.ui.actions.SwipeAction.ActionTypes import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.compose.ChooseRatingDialog +import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.dialog.SwipeActionsDialog import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment import ac.mdiq.podcini.ui.fragment.DownloadsFragment @@ -32,13 +34,35 @@ import ac.mdiq.podcini.util.Logd import android.content.Context import android.content.SharedPreferences import android.os.Handler +import android.util.TypedValue +import android.view.ViewGroup import androidx.annotation.OptIn +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.media3.common.util.UnstableApi -import androidx.recyclerview.widget.ItemTouchHelper -import com.annimon.stream.Stream import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -80,25 +104,17 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) class Actions(prefs: String?) { @JvmField - var right: SwipeAction? = null + var right: SwipeAction = swipeActions[0] @JvmField - var left: SwipeAction? = null + var left: SwipeAction = swipeActions[0] init { val actions = prefs!!.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() if (actions.size == 2) { - this.right = Stream.of(swipeActions).filter { a: SwipeAction -> a.getId().equals(actions[0]) }.single() - this.left = Stream.of(swipeActions).filter { a: SwipeAction -> a.getId().equals(actions[1]) }.single() - } - } - fun hasActions(): Boolean { - return right != null && left != null - } - fun hasActions(swipeDir: Int): Boolean { - return when (swipeDir) { - ItemTouchHelper.RIGHT -> right != null && right?.getId() != NO_ACTION - ItemTouchHelper.LEFT -> left != null && left?.getId() != NO_ACTION - else -> false + val rActs = swipeActions.filter { a: SwipeAction -> a.getId().equals(actions[0]) } + this.right = if (rActs.isEmpty()) swipeActions[0] else rActs[0] + val lActs = swipeActions.filter { a: SwipeAction -> a.getId().equals(actions[1]) } + this.left = if (lActs.isEmpty()) swipeActions[0] else lActs[0] } } } @@ -109,15 +125,14 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) const val KEY_PREFIX_NO_ACTION: String = "PrefNoSwipeAction" var prefs: SharedPreferences? = null - fun getSharedPrefs(context: Context) { if (prefs == null) prefs = context.getSharedPreferences(SWIPE_ACTIONS_PREF_NAME, Context.MODE_PRIVATE) } @JvmField val swipeActions: List = listOf( - NoActionSwipeAction(), AddToQueueSwipeAction(), - StartDownloadSwipeAction(), ShiftRatingSwipeAction(), + NoActionSwipeAction(), ComboSwipeAction(), AddToQueueSwipeAction(), + StartDownloadSwipeAction(), SetRatingSwipeAction(), TogglePlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(), DeleteSwipeAction(), RemoveFromHistorySwipeAction()) @@ -133,11 +148,11 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) @OptIn(UnstableApi::class) @JvmStatic fun getPrefsWithDefaults(tag: String): Actions { val defaultActions = when (tag) { - QueuesFragment.TAG -> "$NO_ACTION,$NO_ACTION" - DownloadsFragment.TAG -> "$NO_ACTION,$NO_ACTION" - HistoryFragment.TAG -> "$NO_ACTION,$NO_ACTION" - AllEpisodesFragment.TAG -> "$NO_ACTION,$NO_ACTION" - else -> "$NO_ACTION,$NO_ACTION" + QueuesFragment.TAG -> "${NO_ACTION.name},${NO_ACTION.name}" + DownloadsFragment.TAG -> "${NO_ACTION.name},${NO_ACTION.name}" + HistoryFragment.TAG -> "${NO_ACTION.name},${NO_ACTION.name}" + AllEpisodesFragment.TAG -> "${NO_ACTION.name},${NO_ACTION.name}" + else -> "${NO_ACTION.name},${NO_ACTION.name}" } return getPrefs(tag, defaultActions) } @@ -149,7 +164,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) class AddToQueueSwipeAction : SwipeAction { override fun getId(): String { - return SwipeAction.ADD_TO_QUEUE + return ActionTypes.ADD_TO_QUEUE.name } override fun getActionIcon(): Int { @@ -167,7 +182,65 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { addToQueue( true, item) -// else RemoveFromQueueSwipeAction().performAction(item, fragment, filter) + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return filter.showQueued || filter.showNew + } + } + + class ComboSwipeAction : SwipeAction { + override fun getId(): String { + return ActionTypes.COMBO.name + } + + override fun getActionIcon(): Int { + return R.drawable.baseline_category_24 + } + + override fun getActionColor(): Int { + return androidx.appcompat.R.attr.colorAccent + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.add_to_queue_label) + } + + @OptIn(UnstableApi::class) + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + val composeView = ComposeView(fragment.requireContext()).apply { + setContent { + var showDialog by remember { mutableStateOf(true) } + CustomTheme(fragment.requireContext()) { + if (showDialog) Dialog(onDismissRequest = { + showDialog = false + (fragment.view as? ViewGroup)?.removeView(this@apply) + }) { + val context = LocalContext.current + Surface(shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + for (action in swipeActions) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { + action.performAction(item, fragment, filter) + showDialog = false + (fragment.view as? ViewGroup)?.removeView(this@apply) + }) { + val colorAccent = remember { + val typedValue = TypedValue() + context.theme.resolveAttribute(action.getActionColor(), typedValue, true) + Color(typedValue.data) + } + Icon(imageVector = ImageVector.vectorResource(id = action.getActionIcon()), tint = colorAccent, contentDescription = action.getTitle(context)) + Text(action.getTitle(context), Modifier.padding(start = 4.dp)) + } + } + } + } + } + } + } + } + (fragment.view as? ViewGroup)?.addView(composeView) } override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { @@ -177,7 +250,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) class DeleteSwipeAction : SwipeAction { override fun getId(): String { - return SwipeAction.DELETE + return ActionTypes.DELETE.name } override fun getActionIcon(): Int { @@ -203,9 +276,9 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } } - class ShiftRatingSwipeAction : SwipeAction { + class SetRatingSwipeAction : SwipeAction { override fun getId(): String { - return SwipeAction.MARK_FAV + return ActionTypes.MARK_FAV.name } override fun getActionIcon(): Int { @@ -222,7 +295,18 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { - setRating(item, item.shiftRating()) + var showChooseRatingDialog by mutableStateOf(true) + val composeView = ComposeView(fragment.requireContext()).apply { + setContent { + CustomTheme(fragment.requireContext()) { + if (showChooseRatingDialog) ChooseRatingDialog(listOf(item)) { + showChooseRatingDialog = false + (fragment.view as? ViewGroup)?.removeView(this@apply) + } + } + } + } + (fragment.view as? ViewGroup)?.addView(composeView) } override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { @@ -232,7 +316,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) class NoActionSwipeAction : SwipeAction { override fun getId(): String { - return NO_ACTION + return NO_ACTION.name } override fun getActionIcon(): Int { @@ -259,7 +343,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) val TAG = this::class.simpleName ?: "Anonymous" override fun getId(): String { - return SwipeAction.REMOVE_FROM_HISTORY + return ActionTypes.REMOVE_FROM_HISTORY.name } override fun getActionIcon(): Int { @@ -306,7 +390,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) class RemoveFromQueueSwipeAction : SwipeAction { override fun getId(): String { - return SwipeAction.REMOVE_FROM_QUEUE + return ActionTypes.REMOVE_FROM_QUEUE.name } override fun getActionIcon(): Int { @@ -360,35 +444,9 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) } } - class ShowFirstSwipeDialogAction : SwipeAction { - override fun getId(): String { - return "SHOW_FIRST_SWIPE_DIALOG" - } - - override fun getActionIcon(): Int { - return R.drawable.ic_settings - } - - override fun getActionColor(): Int { - return R.attr.icon_gray - } - - override fun getTitle(context: Context): String { - return "" - } - - override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { - //handled in SwipeActions - } - - override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { - return false - } - } - class StartDownloadSwipeAction : SwipeAction { override fun getId(): String { - return SwipeAction.START_DOWNLOAD + return ActionTypes.START_DOWNLOAD.name } override fun getActionIcon(): Int { @@ -416,7 +474,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String) class TogglePlaybackStateSwipeAction : SwipeAction { override fun getId(): String { - return SwipeAction.TOGGLE_PLAYED + return ActionTypes.TOGGLE_PLAYED.name } override fun getActionIcon(): Int { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 9f290111..058e0ae4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -81,7 +81,6 @@ import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest -import org.apache.commons.lang3.ArrayUtils import kotlin.math.min /** @@ -419,7 +418,8 @@ class MainActivity : CastEnabledActivity() { QueuesFragment.TAG -> fragment = QueuesFragment() AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment() DownloadsFragment.TAG -> fragment = DownloadsFragment() - SharedLogFragment.TAG -> fragment = SharedLogFragment() + LogsFragment.TAG -> fragment = LogsFragment() +// SubscriptionLogFragment.TAG -> fragment = SubscriptionLogFragment() HistoryFragment.TAG -> fragment = HistoryFragment() OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment() SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment() @@ -638,8 +638,10 @@ class MainActivity : CastEnabledActivity() { else -> handleDeeplink(intent.data) } if (intent.getBooleanExtra(MainActivityStarter.Extras.open_drawer.name, false)) drawerLayout?.open() - if (intent.getBooleanExtra(MainActivityStarter.Extras.open_download_logs.name, false)) - DownloadLogFragment().show(supportFragmentManager, null) +// if (intent.getBooleanExtra(MainActivityStarter.Extras.open_download_logs.name, false)) +// DownloadLogFragment().show(supportFragmentManager, null) + if (intent.getBooleanExtra(MainActivityStarter.Extras.open_logs.name, false)) + loadChildFragment(LogsFragment()) if (intent.getBooleanExtra(Extras.refresh_on_start.name, false)) runOnceOrAsk(this) // to avoid handling the intent twice when the configuration changes diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt index b4ee91d5..2155e116 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt @@ -57,7 +57,7 @@ class MainActivityStarter(private val context: Context) { } fun withDownloadLogsOpen(): MainActivityStarter { - intent.putExtra(Extras.open_download_logs.name, true) + intent.putExtra(Extras.open_logs.name, true) return this } @@ -74,7 +74,7 @@ class MainActivityStarter(private val context: Context) { add_to_back_stack, fragment_tag, open_drawer, - open_download_logs, + open_logs, fragment_args } companion object { 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 958351ee..5f2e6dcb 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 @@ -1,11 +1,10 @@ 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.border +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.* @@ -13,7 +12,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @@ -82,4 +86,38 @@ fun AutoCompleteTextView(suggestions: List, onItemSelected: (String) -> } } } -} \ No newline at end of file +} + +@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) { + val textColor = MaterialTheme.colorScheme.onSurface + 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) + } + } + } +} 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 7d1d19c3..79c50746 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 @@ -106,7 +106,6 @@ class EpisodeVM(var episode: Episode) { 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) var actionButton by mutableStateOf(null) var actionRes by mutableIntStateOf(R.drawable.ic_questionmark) var showAltActionsDialog by mutableStateOf(false) @@ -181,7 +180,7 @@ fun ChooseRatingDialog(selected: List, 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) { + for (rating in Rating.entries) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { for (item in selected) Episodes.setRating(item, rating.code) onDismissRequest() @@ -207,7 +206,7 @@ fun PutToQueueDialog(selected: List, onDismissRequest: () -> Unit) { for (q in queues) { Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = toQueue == q, onClick = { toQueue = q }) - Text(q.name,) + Text(q.name) } } Row(verticalAlignment = Alignment.CenterVertically) { @@ -259,7 +258,7 @@ fun ShelveDialog(selected: List, onDismissRequest: () -> Unit) { for (f in synthetics) { Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = toFeed == f, onClick = { toFeed = f }) - Text(f.title ?: "No title",) + Text(f.title ?: "No title") } } Row(verticalAlignment = Alignment.CenterVertically) { @@ -553,11 +552,11 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, }, onLongClick = { selectMode = !selectMode vm.isSelected = selectMode + selected.clear() if (selectMode) { selected.add(vms[index].episode) longPressIndex = index } else { - selected.clear() selectedSize = 0 longPressIndex = -1 } @@ -572,9 +571,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)) - 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)) + val ratingIconRes = Rating.fromCode(vm.ratingState).res + if (vm.ratingState != Rating.UNRATED.code) + Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt new file mode 100644 index 00000000..93542f85 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt @@ -0,0 +1,221 @@ +package ac.mdiq.podcini.ui.compose + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.net.feed.FeedBuilder +import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult +import ac.mdiq.podcini.storage.database.Feeds +import ac.mdiq.podcini.storage.database.Feeds.deleteFeedSync +import ac.mdiq.podcini.storage.database.RealmDB.upsert +import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.model.Rating +import ac.mdiq.podcini.storage.model.SubscriptionLog +import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment +import ac.mdiq.podcini.ui.fragment.OnlineFeedFragment +import ac.mdiq.podcini.util.EventFlow +import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter +import android.util.Log +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +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.window.Dialog +import androidx.constraintlayout.compose.ConstraintLayout +import coil.compose.AsyncImage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* + +@Composable +fun ChooseRatingDialog(selected: List, onDismissRequest: () -> Unit) { + Dialog(onDismissRequest = onDismissRequest) { + Surface(shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + for (rating in Rating.entries) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { + for (item in selected) Feeds.setRating(item, rating.code) + onDismissRequest() + }) { + Icon(imageVector = ImageVector.vectorResource(id = rating.res), "") + Text(rating.name, Modifier.padding(start = 4.dp)) + } + } + } + } + } +} + +@Composable +fun RemoveFeedDialog(feeds: List, onDismissRequest: () -> Unit, callback: Runnable?) { + val message = if (feeds.size == 1) { + if (feeds[0].isLocalFeed) stringResource(R.string.feed_delete_confirmation_local_msg) + feeds[0].title + else stringResource(R.string.feed_delete_confirmation_msg) + feeds[0].title + } else stringResource(R.string.feed_delete_confirmation_msg_batch) + val textColor = MaterialTheme.colorScheme.onSurface + var textState by remember { mutableStateOf(TextFieldValue("")) } + val context = LocalContext.current + + Dialog(onDismissRequest = onDismissRequest) { + Surface(shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(message) + Text(stringResource(R.string.feed_delete_reason_msg)) + BasicTextField(value = textState, onValueChange = { textState = it }, + textStyle = TextStyle(fontSize = 16.sp, color = textColor), + modifier = Modifier.fillMaxWidth().height(100.dp).padding(start = 10.dp, end = 10.dp, bottom = 10.dp).border(1.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) + ) + Button(onClick = { + callback?.run() + CoroutineScope(Dispatchers.IO).launch { + try { + for (f in feeds) { + val sLog = SubscriptionLog(f.id, f.title?:"", f.downloadUrl?:"", f.link?:"", SubscriptionLog.Type.Feed.name) + upsert(sLog) { + it.rating = f.rating + it.comment = f.comment + it.comment += "\nReason to remove:\n" + textState.text + it.cancelDate = Date().time + } + deleteFeedSync(context, f.id, false) + } + EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.REMOVED, feeds.map { it.id })) + } catch (e: Throwable) { Log.e("RemoveFeedDialog", Log.getStackTraceString(e)) } + } + onDismissRequest() + }) { + Text("Confirm") + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult, log: SubscriptionLog? = null) { + val showSubscribeDialog = remember { mutableStateOf(false) } + @Composable + fun confirmSubscribe(feed: PodcastSearchResult, showDialog: Boolean, onDismissRequest: () -> Unit) { + if (showDialog) { + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), + shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) { + Text("Subscribe: \"${feed.title}\" ?") + Button(onClick = { + CoroutineScope(Dispatchers.IO).launch { + if (feed.feedUrl != null) { + val feedBuilder = FeedBuilder(activity) { message, details -> + Logd("OnineFeedItem", "Subscribe error: $message \n $details") + } + feedBuilder.feedSource = feed.source + feedBuilder.startFeedBuilding(feed.feedUrl, + "", + "") { feed, _ -> feedBuilder.subscribe(feed) } + } + } + onDismissRequest() + }) { + Text("Confirm") + } + } + } + } + } + } + if (showSubscribeDialog.value) { + confirmSubscribe(feed, showSubscribeDialog.value, onDismissRequest = { + showSubscribeDialog.value = false + }) + } + Column(Modifier.padding(start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp).combinedClickable( + onClick = { + if (feed.feedUrl != null) { + if (feed.feedId > 0) { + val fragment = FeedEpisodesFragment.newInstance(feed.feedId) + activity.loadChildFragment(fragment) + } else { + val fragment = OnlineFeedFragment.newInstance(feed.feedUrl) + fragment.feedSource = feed.source + activity.loadChildFragment(fragment) + } + } + }, onLongClick = { showSubscribeDialog.value = true })) { + val textColor = MaterialTheme.colorScheme.onSurface + Text(feed.title, + color = textColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp)) + Row { + ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) { + val (imgvCover, checkMark) = createRefs() + AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover", + placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher), + modifier = Modifier.width(65.dp).height(65.dp).constrainAs(imgvCover) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + }) + + if (feed.feedId > 0 || log != null) { + Logd("OnlineFeedItem", "${feed.feedId} $log") + val alpha = 1.0f + val iRes = if (feed.feedId > 0) R.drawable.ic_check else R.drawable.baseline_clear_24 + Icon(painter = painterResource(iRes), tint = textColor, contentDescription = "played_mark", + modifier = Modifier.background(Color.Green).alpha(alpha).constrainAs(checkMark) { + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + }) + } + } + Column(Modifier.padding(start = 10.dp)) { + var authorText by remember { mutableStateOf("") } + authorText = when { + !feed.author.isNullOrBlank() -> feed.author.trim { it <= ' ' } + feed.feedUrl != null && !feed.feedUrl.contains("itunes.apple.com") -> feed.feedUrl + else -> "" + } + if (authorText.isNotEmpty()) Text(authorText, + color = textColor, + style = MaterialTheme.typography.bodyMedium) + if (feed.subscriberCount > 0) Text(MiscFormatter.formatNumber(feed.subscriberCount) + " subscribers", + color = textColor, + style = MaterialTheme.typography.bodyMedium) + Row { + if (feed.count != null && feed.count > 0) Text(feed.count.toString() + " episodes", + color = textColor, + style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.weight(1f)) + if (feed.update != null) Text(feed.update, + color = textColor, + style = MaterialTheme.typography.bodyMedium) + } + Text(feed.source + ": " + feed.feedUrl, + color = textColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/OnlineFeed.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/OnlineFeed.kt deleted file mode 100644 index 9bfcbcea..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/OnlineFeed.kt +++ /dev/null @@ -1,118 +0,0 @@ -package ac.mdiq.podcini.ui.compose - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.net.feed.FeedBuilder -import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult -import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment -import ac.mdiq.podcini.ui.fragment.OnlineFeedFragment -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.MiscFormatter.formatNumber -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.constraintlayout.compose.ConstraintLayout -import coil.compose.AsyncImage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult) { - val showSubscribeDialog = remember { mutableStateOf(false) } - @Composable - fun confirmSubscribe(feed: PodcastSearchResult, showDialog: Boolean, onDismissRequest: () -> Unit) { - if (showDialog) { - Dialog(onDismissRequest = { onDismissRequest() }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) { - Text("Subscribe: \"${feed.title}\" ?") - Button(onClick = { - CoroutineScope(Dispatchers.IO).launch { - if (feed.feedUrl != null) { - val feedBuilder = FeedBuilder(activity) { - message, details -> Logd("OnineFeedItem", "Subscribe error: $message \n $details") - } - feedBuilder.feedSource = feed.source - feedBuilder.startFeedBuilding(feed.feedUrl, "", "") { feed, _ -> feedBuilder.subscribe(feed)} - } - } - onDismissRequest() - }) { - Text("Confirm") - } - } - } - } - } - } - if (showSubscribeDialog.value) { - confirmSubscribe(feed, showSubscribeDialog.value, onDismissRequest = { - showSubscribeDialog.value = false - }) - } - Column(Modifier.padding(start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp).combinedClickable( - onClick = { - if (feed.feedUrl != null) { - if (feed.feedId > 0) { - val fragment = FeedEpisodesFragment.newInstance(feed.feedId) - activity.loadChildFragment(fragment) - } else { - val fragment = OnlineFeedFragment.newInstance(feed.feedUrl) - fragment.feedSource = feed.source - activity.loadChildFragment(fragment) - } - } - }, onLongClick = { showSubscribeDialog.value = true })) { - val textColor = MaterialTheme.colorScheme.onSurface - Text(feed.title, color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = 4.dp)) - Row { - ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) { - val (imgvCover, checkMark) = createRefs() - AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover", - placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher), - modifier = Modifier.width(65.dp).height(65.dp).constrainAs(imgvCover) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - }) - val alpha = if (feed.feedId > 0) 1.0f else 0f - if (feed.feedId > 0) Icon(painter = painterResource(R.drawable.ic_check), tint = textColor, contentDescription = "played_mark", - modifier = Modifier.background(Color.Green).alpha(alpha).constrainAs(checkMark) { - bottom.linkTo(parent.bottom) - end.linkTo(parent.end) - }) - } - Column(Modifier.padding(start = 10.dp)) { - var authorText by remember { mutableStateOf("") } - authorText = when { - !feed.author.isNullOrBlank() -> feed.author.trim { it <= ' ' } - feed.feedUrl != null && !feed.feedUrl.contains("itunes.apple.com") -> feed.feedUrl - else -> "" - } - if (authorText.isNotEmpty()) Text(authorText, color = textColor, style = MaterialTheme.typography.bodyMedium) - if (feed.subscriberCount > 0) Text(formatNumber(feed.subscriberCount) + " subscribers", color = textColor, style = MaterialTheme.typography.bodyMedium) - Row { - if (feed.count != null && feed.count > 0) Text(feed.count.toString() + " episodes", color = textColor, style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.weight(1f)) - if (feed.update != null) Text(feed.update, color = textColor, style = MaterialTheme.typography.bodyMedium) - } - Text(feed.source + ": " + feed.feedUrl, color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) - } - } - } -} - diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt index dbc58da5..a41bf4b9 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/RemoveFeedDialog.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +// only used in SearchFragment object RemoveFeedDialog { private val TAG: String = RemoveFeedDialog::class.simpleName ?: "Anonymous" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt index a8dc88da..d8866aec 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt @@ -1,27 +1,26 @@ package ac.mdiq.podcini.ui.dialog -import android.content.Context -import android.content.DialogInterface -import android.graphics.PorterDuff -import android.view.LayoutInflater -import android.view.View -import android.widget.CompoundButton -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.graphics.drawable.DrawableCompat -import androidx.gridlayout.widget.GridLayout -import com.annimon.stream.Stream -import com.google.android.material.dialog.MaterialAlertDialogBuilder import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.* -import ac.mdiq.podcini.ui.fragment.* import ac.mdiq.podcini.ui.actions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeActions import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.getPrefsWithDefaults import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.getSharedPrefs import ac.mdiq.podcini.ui.actions.SwipeActions.Companion.isSwipeActionEnabled +import ac.mdiq.podcini.ui.fragment.* import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr +import android.content.Context +import android.content.DialogInterface +import android.graphics.PorterDuff +import android.view.LayoutInflater +import android.view.View +import android.widget.CompoundButton import androidx.annotation.OptIn +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.drawable.DrawableCompat +import androidx.gridlayout.widget.GridLayout import androidx.media3.common.util.UnstableApi +import com.google.android.material.dialog.MaterialAlertDialogBuilder @OptIn(UnstableApi::class) class SwipeActionsDialog(private val context: Context, private val tag: String) { @@ -43,30 +42,30 @@ class SwipeActionsDialog(private val context: Context, private val tag: String) when (tag) { AllEpisodesFragment.TAG -> { forFragment = context.getString(R.string.episodes_label) - keys = Stream.of(keys).filter { a: SwipeAction -> !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY) }.toList() + keys = keys.filter { a: SwipeAction -> !a.getId().equals(SwipeAction.ActionTypes.REMOVE_FROM_HISTORY.name) } } DownloadsFragment.TAG -> { forFragment = context.getString(R.string.downloads_label) - keys = Stream.of(keys).filter { a: SwipeAction -> - (!a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY) && !a.getId().equals(SwipeAction.START_DOWNLOAD)) }.toList() + keys = keys.filter { a: SwipeAction -> + (!a.getId().equals(SwipeAction.ActionTypes.REMOVE_FROM_HISTORY.name) && !a.getId().equals(SwipeAction.ActionTypes.START_DOWNLOAD.name)) } } FeedEpisodesFragment.TAG -> { forFragment = context.getString(R.string.individual_subscription) - keys = Stream.of(keys).filter { a: SwipeAction -> !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY) }.toList() + keys = keys.filter { a: SwipeAction -> !a.getId().equals(SwipeAction.ActionTypes.REMOVE_FROM_HISTORY.name) } } QueuesFragment.TAG -> { forFragment = context.getString(R.string.queue_label) // keys = Stream.of(keys).filter { a: SwipeAction -> // (!a.getId().equals(SwipeAction.ADD_TO_QUEUE) && !a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY)) }.toList() - keys = Stream.of(keys).filter { a: SwipeAction -> (!a.getId().equals(SwipeAction.REMOVE_FROM_HISTORY)) }.toList() + keys = keys.filter { a: SwipeAction -> (!a.getId().equals(SwipeAction.ActionTypes.REMOVE_FROM_HISTORY.name)) } } HistoryFragment.TAG -> { forFragment = context.getString(R.string.playback_history_label) - keys = Stream.of(keys).toList() + keys = keys.toList() } else -> {} } - if (tag != QueuesFragment.TAG) keys = Stream.of(keys).filter { a: SwipeAction -> !a.getId().equals(SwipeAction.REMOVE_FROM_QUEUE) }.toList() + if (tag != QueuesFragment.TAG) keys = keys.filter { a: SwipeAction -> !a.getId().equals(SwipeAction.ActionTypes.REMOVE_FROM_QUEUE.name) } builder.setTitle(context.getString(R.string.swipeactions_label) + " - " + forFragment) val binding = SwipeactionsDialogBinding.inflate(LayoutInflater.from(context)) 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 fd1d4abc..0b236af5 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 @@ -39,7 +39,6 @@ import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter import ac.mdiq.podcini.ui.compose.ChooseRatingDialog import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.dialog.* -import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment.EpisodeHomeFragment.Companion.episode import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.view.ShownotesWebView import ac.mdiq.podcini.util.EventFlow @@ -361,7 +360,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { }, onLongClick = { copyText(currentMedia?.getFeedTitle()?:"") })) Row(modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 2.dp), ) { Spacer(modifier = Modifier.weight(0.2f)) - var ratingIconRes = Episode.Rating.fromCode(rating).res + var ratingIconRes = Rating.fromCode(rating).res Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.width(15.dp).height(15.dp).clickable(onClick = { showChooseRatingDialog = true })) 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 0728eecb..9b602ab5 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 @@ -224,9 +224,7 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene Logd(TAG, "loadItems() called") lifecycleScope.launch { try { - val data = withContext(Dispatchers.IO) { - Pair(loadData().toMutableList(), loadTotalItemCount()) - } + val data = withContext(Dispatchers.IO) { Pair(loadData().toMutableList(), loadTotalItemCount()) } val restoreScrollPosition = episodes.isEmpty() episodes.clear() episodes.addAll(data.first) @@ -236,11 +234,8 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene // if (restoreScrollPosition) recyclerView.restoreScrollPosition(getPrefName()) updateToolbar() } - } catch (e: Throwable) { - Log.e(TAG, Log.getStackTraceString(e)) - } finally { - loadItemsRunning = false - } + } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) + } finally { loadItemsRunning = false } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadLogFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadLogFragment.kt deleted file mode 100644 index c7675e10..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadLogFragment.kt +++ /dev/null @@ -1,282 +0,0 @@ -package ac.mdiq.podcini.ui.fragment - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.DownloadlogFragmentBinding -import ac.mdiq.podcini.net.feed.FeedUpdateManager -import ac.mdiq.podcini.storage.database.Feeds.getFeed -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope -import ac.mdiq.podcini.storage.model.DownloadResult -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.utils.DownloadResultComparator -import ac.mdiq.podcini.ui.actions.DownloadActionButton -import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.error.DownloadErrorLabel.from -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Build -import android.os.Bundle -import android.text.format.DateUtils -import android.util.Log -import android.view.* -import android.widget.Toast -import androidx.appcompat.widget.Toolbar -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class DownloadLogFragment : BottomSheetDialogFragment(), Toolbar.OnMenuItemClickListener { - private var _binding: DownloadlogFragmentBinding? = null - private val binding get() = _binding!! - - private val downloadLog = mutableStateListOf() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - Logd(TAG, "fragment onCreateView") - _binding = DownloadlogFragmentBinding.inflate(inflater) - binding.toolbar.inflateMenu(R.menu.download_log) - binding.toolbar.setOnMenuItemClickListener(this) - - binding.lazyColumn.setContent { - CustomTheme(requireContext()) { - MainView() - } - } - loadDownloadLog() - return binding.root - } - - override fun onStart() { - super.onStart() - procFlowEvents() - } - - override fun onStop() { - super.onStop() - cancelFlowEvents() - } - - override fun onDestroyView() { - Logd(TAG, "onDestroyView") - _binding = null - downloadLog.clear() - super.onDestroyView() - } - - @Composable - fun MainView() { - val lazyListState = rememberLazyListState() - val showDialog = remember { mutableStateOf(false) } - val dialogParam = remember { mutableStateOf(DownloadResult()) } - if (showDialog.value) { - DetailDialog( - status = dialogParam.value, - showDialog = showDialog.value, - onDismissRequest = { showDialog.value = false }, - ) - } - LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 5.dp, bottom = 5.dp), - verticalArrangement = Arrangement.spacedBy(8.dp)) { - itemsIndexed(downloadLog) { position, status -> - val textColor = MaterialTheme.colorScheme.onSurface - Row (modifier = Modifier.clickable { - showDialog.value = true - dialogParam.value = status - }) { - Column { - Row { - val icon = remember { if (status.isSuccessful) Icons.Filled.Info else Icons.Filled.Warning } - val iconColor = remember { if (status.isSuccessful) Color.Green else Color.Yellow } - Icon(icon, "Info", tint = iconColor, modifier = Modifier.padding(end = 2.dp)) - Text(status.title.ifEmpty { stringResource(R.string.download_log_title_unknown) }, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - val statusText = remember {"" + - when (status.feedfileType) { - Feed.FEEDFILETYPE_FEED -> requireContext().getString(R.string.download_type_feed) - EpisodeMedia.FEEDFILETYPE_FEEDMEDIA -> requireContext().getString(R.string.download_type_media) - else -> "" } + " · " + - DateUtils.getRelativeTimeSpanString(status.getCompletionDate().time, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, 0) - } - Text(statusText, color = textColor) - if (!status.isSuccessful) { - Text(stringResource(from(status.reason)), color = Color.Red) - Text(stringResource(R.string.download_error_tap_for_details), color = textColor) - } - } - fun newerWasSuccessful(downloadStatusIndex: Int, feedTypeId: Int, id: Long): Boolean { - for (i in 0 until downloadStatusIndex) { - val status_: DownloadResult = downloadLog[i] - if (status_.feedfileType == feedTypeId && status_.feedfileId == id && status_.isSuccessful) return true - } - return false - } - var showAction by remember { mutableStateOf(!status.isSuccessful && !newerWasSuccessful(position, status.feedfileType, status.feedfileId)) } - if (showAction) { - Icon(painter = painterResource(R.drawable.ic_refresh), - tint = textColor, - contentDescription = null, - modifier = Modifier.width(28.dp).height(32.dp).clickable { - when (status.feedfileType) { - Feed.FEEDFILETYPE_FEED -> { - showAction = false - val feed: Feed? = getFeed(status.feedfileId) - if (feed == null) { - Log.e(TAG, "Could not find feed for feed id: " + status.feedfileId) - return@clickable - } - FeedUpdateManager.runOnce(requireContext(), feed) - } - EpisodeMedia.FEEDFILETYPE_FEEDMEDIA -> { - showAction = false - val item_ = realm.query(Episode::class).query("id == $0", status.feedfileId).first().find() - if (item_ != null) DownloadActionButton(item_).onClick(requireContext()) - (context as MainActivity).showSnackbarAbovePlayer(R.string.status_downloading_label, Toast.LENGTH_SHORT) - } - } - }) - } - } - } - } - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.DownloadLogEvent -> loadDownloadLog() - else -> {} - } - } - } - } - - @Deprecated("Deprecated in Java") - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.clear_logs_item).setVisible(downloadLog.isNotEmpty()) - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - when { - super.onOptionsItemSelected(item) -> return true - item.itemId == R.id.clear_logs_item -> { - runOnIOScope { - realm.write { - val dlog = query(DownloadResult::class).find() - delete(dlog) - } - EventFlow.postEvent(FlowEvent.DownloadLogEvent()) - } - } - else -> return false - } - return true - } - - private fun loadDownloadLog() { - lifecycleScope.launch { - try { - val result = withContext(Dispatchers.IO) { - Logd(TAG, "getDownloadLog() called") - val dlog = realm.query(DownloadResult::class).find().toMutableList() - dlog.sortWith(DownloadResultComparator()) - realm.copyFromRealm(dlog) - } - withContext(Dispatchers.Main) { - downloadLog.clear() - downloadLog.addAll(result) - } - } catch (e: Throwable) { - Log.e(TAG, Log.getStackTraceString(e)) - } - } - } - - @Composable - fun DetailDialog(status: DownloadResult, showDialog: Boolean, onDismissRequest: () -> Unit) { - if (showDialog) { - var url = "unknown" - when (status.feedfileType) { - EpisodeMedia.FEEDFILETYPE_FEEDMEDIA -> { - val media = realm.query(EpisodeMedia::class).query("id == $0", status.feedfileId).first().find() - if (media != null) url = media.downloadUrl?:"" - } - Feed.FEEDFILETYPE_FEED -> { - val feed = getFeed(status.feedfileId, false) - if (feed != null) url = feed.downloadUrl?:"" - } - } - var message = requireContext().getString(R.string.download_successful) - if (!status.isSuccessful) message = status.reasonDetailed - val messageFull = requireContext().getString(R.string.download_log_details_message, requireContext().getString(from(status.reason)), message, url) - - Dialog(onDismissRequest = { onDismissRequest() }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp), ) { - Column(modifier = Modifier.padding(10.dp)) { - val textColor = MaterialTheme.colorScheme.onSurface - Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp)) - Text(messageFull, color = textColor) - Row(Modifier.padding(top = 10.dp)) { - Spacer(Modifier.weight(0.5f)) - Text(stringResource(R.string.copy_to_clipboard), color = textColor, - modifier = Modifier.clickable { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(requireContext().getString(R.string.download_error_details), messageFull) - clipboard.setPrimaryClip(clip) - if (Build.VERSION.SDK_INT < 32) - EventFlow.postEvent(FlowEvent.MessageEvent(requireContext().getString(R.string.copied_to_clipboard))) - }) - Spacer(Modifier.weight(0.3f)) - Text("OK", color = textColor, modifier = Modifier.clickable { onDismissRequest() }) - Spacer(Modifier.weight(0.2f)) - } - } - } - } - } - } - - companion object { - private val TAG: String = DownloadLogFragment::class.simpleName ?: "Anonymous" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt index 797ebb4a..8162c3c0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt @@ -114,7 +114,7 @@ import java.util.* lifecycle.addObserver(swipeActions) refreshSwipeTelltale() - if (arguments != null && requireArguments().getBoolean(ARG_SHOW_LOGS, false)) DownloadLogFragment().show(childFragmentManager, null) +// if (arguments != null && requireArguments().getBoolean(ARG_SHOW_LOGS, false)) DownloadLogFragment().show(childFragmentManager, null) addEmptyView() return binding.root @@ -155,7 +155,7 @@ import java.util.* @UnstableApi override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.filter_items -> DownloadsFilterDialog.newInstance(getFilter()).show(childFragmentManager, null) - R.id.action_download_logs -> DownloadLogFragment().show(childFragmentManager, null) +// R.id.action_download_logs -> DownloadLogFragment().show(childFragmentManager, null) R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance()) R.id.downloads_sort -> DownloadsSortDialog().show(childFragmentManager, "SortDialog") R.id.switch_queue -> SwitchQueueDialog(activity as MainActivity).show() 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 5b244cf2..1a368995 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 @@ -27,14 +27,15 @@ import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.model.Rating 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.activity.MainActivity import ac.mdiq.podcini.ui.compose.ChooseRatingDialog import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.compose.LargeTextEditingDialog import ac.mdiq.podcini.ui.dialog.ShareDialog -import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment.EpisodeHomeFragment.Companion import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.utils.ThemeUtils import ac.mdiq.podcini.ui.view.ShownotesWebView @@ -58,17 +59,13 @@ 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.* 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.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -121,7 +118,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { private var txtvDuration by mutableStateOf("") private var itemLink by mutableStateOf("") var hasMedia by mutableStateOf(true) - var rating by mutableStateOf(episode?.rating ?: 0) + var rating by mutableStateOf(episode?.rating ?: Rating.UNRATED.code) var inQueue by mutableStateOf(if (episode != null) curQueue.contains(episode!!) else false) var isPlayed by mutableStateOf(episode?.isPlayed() ?: false) @@ -171,38 +168,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { 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 = { @@ -226,8 +191,9 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } Row(modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Modifier.weight(0.4f)) - val playedIconRes = if (isPlayed) R.drawable.ic_mark_unplayed else R.drawable.ic_mark_played - Icon(painter = painterResource(playedIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "isPlayed", modifier = Modifier.width(24.dp).height(24.dp) + val playedIconRes = if (!isPlayed) R.drawable.ic_mark_unplayed else R.drawable.ic_mark_played + Icon(painter = painterResource(playedIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "isPlayed", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(24.dp).height(24.dp) .clickable(onClick = { if (isPlayed) { setPlayState(Episode.PlayState.UNPLAYED.code, false, episode!!) @@ -252,14 +218,18 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } })) + if (episode?.media != null) { + Spacer(modifier = Modifier.weight(0.2f)) + val inQueueIconRes = if (inQueue) R.drawable.ic_playlist_play else R.drawable.ic_playlist_remove + Icon(painter = painterResource(inQueueIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "inQueue", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(24.dp).height(24.dp).clickable(onClick = { + if (inQueue) removeFromQueue(episode!!) else addToQueue(true, episode!!) + })) + } Spacer(modifier = Modifier.weight(0.2f)) - val inQueueIconRes = if (!inQueue && episode?.media != null) R.drawable.ic_playlist_play else R.drawable.ic_playlist_remove - Icon(painter = painterResource(inQueueIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "inQueue", modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = { - if (inQueue) removeFromQueue(episode!!) else addToQueue(true, episode!!) - })) - Spacer(modifier = Modifier.weight(0.2f)) - val ratingIconRes = Episode.Rating.fromCode(rating).res - Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = { + val ratingIconRes = Rating.fromCode(rating).res + Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(24.dp).height(24.dp).clickable(onClick = { showChooseRatingDialog = true })) Spacer(modifier = Modifier.weight(0.2f)) 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 1b5d2ecb..b09dda31 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 @@ -40,10 +40,15 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RenderEffect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -90,6 +95,8 @@ import java.util.concurrent.Semaphore private var enableFilter: Boolean = true private var filterButColor = mutableStateOf(Color.White) + private var showRemoveFeedDialog by mutableStateOf(false) + private val ioScope = CoroutineScope(Dispatchers.IO) private var onInit: Boolean = true @@ -152,14 +159,15 @@ import java.util.concurrent.Semaphore loadItemsRunning = false } } - binding.header.setContent { - CustomTheme(requireContext()) { - FeedEpisodesHeader(activity = (activity as MainActivity), feed = feed, filterButColor = filterButColor.value, filterClickCB = {filterClick()}, filterLongClickCB = {filterLongClick()}) - } - } binding.lazyColumn.setContent { CustomTheme(requireContext()) { + if (showRemoveFeedDialog) RemoveFeedDialog(listOf(feed!!), onDismissRequest = {showRemoveFeedDialog = false}) { + (activity as MainActivity).loadFragment(UserPreferences.defaultPage, null) + // Make sure fragment is hidden before actually starting to delete + requireActivity().supportFragmentManager.executePendingTransactions() + } Column { + FeedEpisodesHeader(activity = (activity as MainActivity), feed = feed, filterButColor = filterButColor.value, filterClickCB = {filterClick()}, filterLongClickCB = {filterLongClick()}) InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()}) EpisodeLazyColumn(activity as MainActivity, vms = vms, refreshCB = { FeedUpdateManager.runOnceOrAsk(requireContext(), feed) }, @@ -216,8 +224,24 @@ import java.util.concurrent.Semaphore fun FeedEpisodesHeader(activity: MainActivity, feed: Feed?, filterButColor: Color, filterClickCB: ()->Unit, filterLongClickCB: ()->Unit) { val textColor = MaterialTheme.colorScheme.onSurface ConstraintLayout(modifier = Modifier.fillMaxWidth().height(120.dp)) { - val (controlRow, image1, image2, imgvCover, taColumn) = createRefs() - Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp).background(colorResource(id = R.color.image_readability_tint)) + val (bgImage, bgColor, controlRow, image1, image2, imgvCover, taColumn) = createRefs() + AsyncImage(model = feed?.imageUrl?:"", contentDescription = "bgImage", contentScale = ContentScale.FillBounds, + error = painterResource(R.drawable.teaser), + modifier = Modifier.fillMaxSize().blur(radiusX = 15.dp, radiusY = 15.dp) + .constrainAs(bgImage) { + bottom.linkTo(parent.bottom) + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }) + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .constrainAs(bgColor) { + bottom.linkTo(parent.bottom) + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }) + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp) .constrainAs(controlRow) { bottom.linkTo(parent.bottom) start.linkTo(parent.start) @@ -259,8 +283,8 @@ import java.util.concurrent.Semaphore Column(Modifier.constrainAs(taColumn) { top.linkTo(imgvCover.top) start.linkTo(imgvCover.end) }) { - Text(feed?.title?:"", color = textColor, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) - Text(feed?.author?:"", color = textColor, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(feed?.title?:"", color = textColor, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(feed?.author?:"", color = textColor, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } @@ -367,12 +391,12 @@ import java.util.concurrent.Semaphore // } // } R.id.rename_feed -> CustomFeedNameDialog(activity as Activity, feed!!).show() - R.id.remove_feed -> { - RemoveFeedDialog.show(requireContext(), feed!!) { - (activity as MainActivity).loadFragment(UserPreferences.defaultPage, null) - // Make sure fragment is hidden before actually starting to delete - requireActivity().supportFragmentManager.executePendingTransactions() - } + R.id.remove_feed -> { showRemoveFeedDialog = true +// RemoveFeedDialog.show(requireContext(), feed!!) { +// (activity as MainActivity).loadFragment(UserPreferences.defaultPage, null) +// // Make sure fragment is hidden before actually starting to delete +// requireActivity().supportFragmentManager.executePendingTransactions() +// } } R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance(feed!!.id, feed!!.title)) R.id.switch_queue -> SwitchQueueDialog(activity as MainActivity).show() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt index 59016a9e..73d981f5 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt @@ -9,11 +9,17 @@ import ac.mdiq.podcini.net.utils.HtmlToPlainText import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.database.Feeds.updateFeedDownloadURL +import ac.mdiq.podcini.storage.database.RealmDB.realm +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.database.RealmDB.upsert import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.FeedFunding +import ac.mdiq.podcini.storage.model.Rating import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.compose.ChooseRatingDialog import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog +import ac.mdiq.podcini.ui.compose.LargeTextEditingDialog +import ac.mdiq.podcini.ui.compose.RemoveFeedDialog import ac.mdiq.podcini.ui.statistics.FeedStatisticsFragment import ac.mdiq.podcini.ui.statistics.FeedStatisticsFragment.Companion.EXTRA_DETAILED import ac.mdiq.podcini.ui.statistics.FeedStatisticsFragment.Companion.EXTRA_FEED_ID @@ -38,14 +44,19 @@ import androidx.appcompat.widget.Toolbar import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +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.constraintlayout.compose.ConstraintLayout @@ -73,8 +84,10 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { private lateinit var feed: Feed private lateinit var toolbar: MaterialToolbar + private var showRemoveFeedDialog by mutableStateOf(false) private var txtvAuthor by mutableStateOf("") var txtvUrl by mutableStateOf(null) + var rating by mutableStateOf(Rating.UNRATED.code) private val addLocalFolderLauncher = registerForActivityResult(AddLocalFolder()) { uri: Uri? -> this.addLocalFolderResult(uri) @@ -104,13 +117,13 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { txtvAuthor = feed.author ?: "" txtvUrl = feed.downloadUrl -// binding.header.setContent { -// CustomTheme(requireContext()) { -// HeaderUI() -// } -// } binding.detailUI.setContent { CustomTheme(requireContext()) { + if (showRemoveFeedDialog) RemoveFeedDialog(listOf(feed), onDismissRequest = {showRemoveFeedDialog = false}) { + (activity as MainActivity).loadFragment(UserPreferences.defaultPage, null) + // Make sure fragment is hidden before actually starting to delete + requireActivity().supportFragmentManager.executePendingTransactions() + } Column { HeaderUI() DetailUI() @@ -141,16 +154,43 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { @Composable fun HeaderUI() { val textColor = MaterialTheme.colorScheme.onSurface + var showChooseRatingDialog by remember { mutableStateOf(false) } + if (showChooseRatingDialog) ChooseRatingDialog(listOf(feed)) { + showChooseRatingDialog = false + setFeed(feed) + } ConstraintLayout(modifier = Modifier.fillMaxWidth().height(130.dp)) { - val (controlRow, image1, image2, imgvCover, taColumn) = createRefs() - Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp).background(colorResource(id = R.color.image_readability_tint)) + val (bgImage, bgColor, controlRow, image1, image2, imgvCover, taColumn) = createRefs() + AsyncImage(model = feed?.imageUrl?:"", contentDescription = "bgImage", contentScale = ContentScale.FillBounds, + error = painterResource(R.drawable.teaser), + modifier = Modifier.fillMaxSize().blur(radiusX = 15.dp, radiusY = 15.dp) + .constrainAs(bgImage) { + bottom.linkTo(parent.bottom) + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }) + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .constrainAs(bgColor) { + bottom.linkTo(parent.bottom) + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }) + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp) .constrainAs(controlRow) { bottom.linkTo(parent.bottom) start.linkTo(parent.start) }, verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Modifier.weight(1f)) - Image(painter = painterResource(R.drawable.ic_settings_white), contentDescription = "butShowSettings", - Modifier.width(40.dp).height(40.dp).padding(3.dp).clickable(onClick = { + val ratingIconRes = Rating.fromCode(rating).res + Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(24.dp).height(24.dp).clickable(onClick = { + showChooseRatingDialog = true + })) + Spacer(modifier = Modifier.weight(0.2f)) + Icon(painter = painterResource(R.drawable.ic_settings_white), tint = textColor, contentDescription = "butShowSettings", + modifier = Modifier.width(40.dp).height(40.dp).padding(3.dp).clickable(onClick = { (activity as MainActivity).loadChildFragment(FeedSettingsFragment.newInstance(feed), TransitionEffect.SLIDE) })) Spacer(modifier = Modifier.weight(0.2f)) @@ -182,8 +222,8 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { Column(Modifier.constrainAs(taColumn) { top.linkTo(imgvCover.top) start.linkTo(imgvCover.end) }) { - Text(feed.title ?:"", color = textColor, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) - Text(txtvAuthor, color = textColor, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(feed.title ?:"", color = textColor, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(txtvAuthor, color = textColor, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } @@ -191,12 +231,33 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { @Composable fun DetailUI() { val scrollState = rememberScrollState() + var showEditComment by remember { mutableStateOf(false) } + var commentTextState by remember { mutableStateOf(TextFieldValue(feed?.comment?:"")) } + if (showEditComment) LargeTextEditingDialog(textState = commentTextState, onTextChange = { commentTextState = it }, onDismissRequest = {showEditComment = false}, + onSave = { + runOnIOScope { + feed = upsert(feed) { it.comment = commentTextState.text } + rating = feed.rating +// val slog = realm.query(SubscriptionLog::class).query("itemId == $0", feed.id).first().find() +// if (slog != null) { +// upsert(slog) { +// it.comment = commentTextState.text +// } +// } + } + }) + Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { val textColor = MaterialTheme.colorScheme.onSurface Text(feed.title ?:"", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp)) Text(feed.author ?:"", color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp)) Text(stringResource(R.string.description_label), color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)) Text(HtmlToPlainText.getPlainText(feed.description?:""), color = textColor, style = MaterialTheme.typography.bodyMedium) + 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(stringResource(R.string.url_label), color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)) Text(text = txtvUrl?:"", color = textColor, modifier = Modifier.clickable { if (feed.downloadUrl != null) { @@ -236,7 +297,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { val fundText = remember { fundingText() } Text(fundText, color = textColor) } - Button({ + Button(modifier = Modifier.padding(top = 10.dp), onClick = { val fragment = SearchResultsFragment.newInstance(CombinedSearcher::class.java, "$txtvAuthor podcasts") (activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE) }) { @@ -270,7 +331,9 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } fun setFeed(feed_: Feed) { - feed = feed_ +// feed = feed_ + feed = realm.query(Feed::class).query("id == $0", feed_.id).first().find()!! + rating = feed.rating } override fun onDestroyView() { @@ -314,11 +377,12 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { }.show() } R.id.remove_feed -> { - RemoveFeedDialog.show(requireContext(), feed) { - (activity as MainActivity).loadFragment(UserPreferences.defaultPage, null) - // Make sure fragment is hidden before actually starting to delete - requireActivity().supportFragmentManager.executePendingTransactions() - } + showRemoveFeedDialog = true +// RemoveFeedDialog.show(requireContext(), feed) { +// (activity as MainActivity).loadFragment(UserPreferences.defaultPage, null) +// // Make sure fragment is hidden before actually starting to delete +// requireActivity().supportFragmentManager.executePendingTransactions() +// } } else -> return false } @@ -377,7 +441,10 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { EventFlow.events.collectLatest { event -> Logd(TAG, "Received event: ${event.TAG}") when (event) { - is FlowEvent.FeedPrefsChangeEvent -> feed = event.feed + is FlowEvent.FeedPrefsChangeEvent -> { + setFeed(feed) +// feed = event.feed + } else -> {} } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/LogsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/LogsFragment.kt new file mode 100644 index 00000000..6fef6fb3 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/LogsFragment.kt @@ -0,0 +1,456 @@ +package ac.mdiq.podcini.ui.fragment + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.LogsFragmentBinding +import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.storage.database.Feeds.getFeed +import ac.mdiq.podcini.storage.database.RealmDB.realm +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.model.* +import ac.mdiq.podcini.storage.model.Rating.Companion.fromCode +import ac.mdiq.podcini.storage.utils.DownloadResultComparator +import ac.mdiq.podcini.ui.actions.DownloadActionButton +import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.activity.ShareReceiverActivity.Companion.receiveShared +import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.compose.confirmAddYoutubeEpisode +import ac.mdiq.podcini.util.EventFlow +import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter.formatDateTimeFlex +import ac.mdiq.podcini.util.error.DownloadErrorLabel.from +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.text.format.DateUtils +import android.util.Log +import android.view.* +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import io.realm.kotlin.query.Sort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.* + +class LogsFragment : Fragment(), Toolbar.OnMenuItemClickListener { + private var _binding: LogsFragmentBinding? = null + private val binding get() = _binding!! + + private val shareLogs = mutableStateListOf() + private val subscriptionLogs = mutableStateListOf() + private val downloadLogs = mutableStateListOf() + +// private var showShared by mutableStateOf(true) +// private var showSubscription by mutableStateOf(false) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + Logd(TAG, "fragment onCreateView") + _binding = LogsFragmentBinding.inflate(inflater) + binding.toolbar.inflateMenu(R.menu.logs) + binding.toolbar.setOnMenuItemClickListener(this) + + binding.lazyColumn.setContent { + CustomTheme(requireContext()) { + when { + downloadLogs.isNotEmpty() -> DownloadLogView() + shareLogs.isNotEmpty() -> SharedLogView() + subscriptionLogs.isNotEmpty() -> SubscriptionLogView() + } + } + } + loadDownloadLog() + return binding.root + } + + override fun onDestroyView() { + Logd(TAG, "onDestroyView") + _binding = null + clearAllLogs() + super.onDestroyView() + } + + @Composable + fun SharedLogView() { + val lazyListState = rememberLazyListState() + val showDialog = remember { mutableStateOf(false) } + val dialogParam = remember { mutableStateOf(ShareLog()) } + if (showDialog.value) { + SharedDetailDialog(status = dialogParam.value, showDialog = showDialog.value, onDismissRequest = { showDialog.value = false }) + } + var showYTMediaConfirmDialog by remember { mutableStateOf(false) } + var sharedUrl by remember { mutableStateOf("") } + if (showYTMediaConfirmDialog) + confirmAddYoutubeEpisode(listOf(sharedUrl), showYTMediaConfirmDialog, onDismissRequest = { showYTMediaConfirmDialog = false }) + + LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 5.dp, bottom = 5.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(shareLogs) { position, log -> + val textColor = MaterialTheme.colorScheme.onSurface + Row (modifier = Modifier.clickable { + if (log.status == 1) { + showDialog.value = true + dialogParam.value = log + } else { + receiveShared(log.url!!, activity as AppCompatActivity, false) { + sharedUrl = log.url!! + showYTMediaConfirmDialog = true + } + } + }) { + Column { + Row { + val icon = remember { if (log.status == 1) Icons.Filled.Info else Icons.Filled.Warning } + val iconColor = remember { if (log.status == 1) Color.Green else Color.Yellow } + Icon(icon, "Info", tint = iconColor, modifier = Modifier.padding(end = 2.dp)) + Text(formatDateTimeFlex(Date(log.id)), color = textColor) + Spacer(Modifier.weight(1f)) + var showAction by remember { mutableStateOf(log.status != 1) } + if (true || showAction) { + Icon(painter = painterResource(R.drawable.ic_delete), tint = textColor, contentDescription = null, + modifier = Modifier.width(25.dp).height(25.dp).clickable { + }) + } + } + Text(log.url?:"unknown", color = textColor) + val statusText = remember {"" } + Text(statusText, color = textColor) + if (log.status != 1) { + Text(log.details, color = Color.Red) + Text(stringResource(R.string.download_error_tap_for_details), color = textColor) + } + } + } + } + } + } + + @Composable + fun SubscriptionLogView() { + val lazyListState = rememberLazyListState() + val showDialog = remember { mutableStateOf(false) } + val dialogParam = remember { mutableStateOf(SubscriptionLog()) } + if (showDialog.value) + SubscriptionDetailDialog(log = dialogParam.value, showDialog = showDialog.value, onDismissRequest = { showDialog.value = false }) + + LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 5.dp, bottom = 5.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(subscriptionLogs) { position, log -> + val textColor = MaterialTheme.colorScheme.onSurface + Row (verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 10.dp, end = 10.dp).clickable { + dialogParam.value = log + showDialog.value = true + }) { + val iconRes = remember { fromCode(log.rating).res } + Icon(painter = painterResource(iconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(40.dp).height(40.dp).padding(end = 15.dp)) + Column { + Text(log.type + ": " + formatDateTimeFlex(Date(log.id)) + " -- " + formatDateTimeFlex(Date(log.cancelDate)), color = textColor) + Text(log.title, color = textColor) + } + } + } + } + } + + @Composable + fun DownloadLogView() { + val lazyListState = rememberLazyListState() + val showDialog = remember { mutableStateOf(false) } + val dialogParam = remember { mutableStateOf(DownloadResult()) } + if (showDialog.value) { + DownlaodDetailDialog( + status = dialogParam.value, + showDialog = showDialog.value, + onDismissRequest = { showDialog.value = false }, + ) + } + LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 5.dp, bottom = 5.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(downloadLogs) { position, status -> + val textColor = MaterialTheme.colorScheme.onSurface + Row (modifier = Modifier.clickable { + showDialog.value = true + dialogParam.value = status + }) { + Column { + Row { + val icon = remember { if (status.isSuccessful) Icons.Filled.Info else Icons.Filled.Warning } + val iconColor = remember { if (status.isSuccessful) Color.Green else Color.Yellow } + Icon(icon, "Info", tint = iconColor, modifier = Modifier.padding(end = 2.dp)) + Text(status.title.ifEmpty { stringResource(R.string.download_log_title_unknown) }, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val statusText = remember {"" + + when (status.feedfileType) { + Feed.FEEDFILETYPE_FEED -> requireContext().getString(R.string.download_type_feed) + EpisodeMedia.FEEDFILETYPE_FEEDMEDIA -> requireContext().getString(R.string.download_type_media) + else -> "" } + " · " + + DateUtils.getRelativeTimeSpanString(status.getCompletionDate().time, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, 0) + } + Text(statusText, color = textColor) + if (!status.isSuccessful) { + Text(stringResource(from(status.reason)), color = Color.Red) + Text(stringResource(R.string.download_error_tap_for_details), color = textColor) + } + } + fun newerWasSuccessful(downloadStatusIndex: Int, feedTypeId: Int, id: Long): Boolean { + for (i in 0 until downloadStatusIndex) { + val status_: DownloadResult = downloadLogs[i] + if (status_.feedfileType == feedTypeId && status_.feedfileId == id && status_.isSuccessful) return true + } + return false + } + var showAction by remember { mutableStateOf(!status.isSuccessful && !newerWasSuccessful(position, status.feedfileType, status.feedfileId)) } + if (showAction) { + Icon(painter = painterResource(R.drawable.ic_refresh), + tint = textColor, + contentDescription = null, + modifier = Modifier.width(28.dp).height(32.dp).clickable { + when (status.feedfileType) { + Feed.FEEDFILETYPE_FEED -> { + showAction = false + val feed: Feed? = getFeed(status.feedfileId) + if (feed == null) { + Log.e(TAG, "Could not find feed for feed id: " + status.feedfileId) + return@clickable + } + FeedUpdateManager.runOnce(requireContext(), feed) + } + EpisodeMedia.FEEDFILETYPE_FEEDMEDIA -> { + showAction = false + val item_ = realm.query(Episode::class).query("id == $0", status.feedfileId).first().find() + if (item_ != null) DownloadActionButton(item_).onClick(requireContext()) + (context as MainActivity).showSnackbarAbovePlayer(R.string.status_downloading_label, Toast.LENGTH_SHORT) + } + } + }) + } + } + } + } + } + + @Deprecated("Deprecated in Java") + override fun onPrepareOptionsMenu(menu: Menu) { + menu.findItem(R.id.clear_logs_item).setVisible(shareLogs.isNotEmpty()) + } + + private fun clearAllLogs() { + subscriptionLogs.clear() + shareLogs.clear() + downloadLogs.clear() + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when { + super.onOptionsItemSelected(item) -> return true + item.itemId == R.id.show_shared_logs -> { + clearAllLogs() + loadShareLog() + } + item.itemId == R.id.show_subscription_logs -> { + clearAllLogs() + loadSubscriptionLog() + } + item.itemId == R.id.show_download_logs -> { + clearAllLogs() + loadDownloadLog() + } + item.itemId == R.id.clear_logs_item -> { + runOnIOScope { + if (shareLogs.isNotEmpty()) { + realm.write { + val dlog = query(ShareLog::class).find() + delete(dlog) + } + loadShareLog() + } else if (subscriptionLogs.isNotEmpty()) { + realm.write { + val dlog = query(SubscriptionLog::class).find() + delete(dlog) + } + loadSubscriptionLog() + } + } + } + else -> return false + } + return true + } + + private fun loadShareLog() { + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + Logd(TAG, "getDownloadLog() called") + realm.query(ShareLog::class).sort("id", Sort.DESCENDING).find().toMutableList() + } + withContext(Dispatchers.Main) { + shareLogs.addAll(result) + } + } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) } + } + } + + private fun loadSubscriptionLog() { + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + Logd(TAG, "getDownloadLog() called") + realm.query(SubscriptionLog::class).sort("id", Sort.DESCENDING).find().toMutableList() + } + withContext(Dispatchers.Main) { + subscriptionLogs.addAll(result) + } + } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) } + } + } + + private fun loadDownloadLog() { + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + Logd(TAG, "getDownloadLog() called") + val dlog = realm.query(DownloadResult::class).find().toMutableList() + dlog.sortWith(DownloadResultComparator()) + realm.copyFromRealm(dlog) + } + withContext(Dispatchers.Main) { + downloadLogs.addAll(result) + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + } + } + + @Composable + fun SharedDetailDialog(status: ShareLog, showDialog: Boolean, onDismissRequest: () -> Unit) { + if (showDialog) { + var message = requireContext().getString(R.string.download_successful) + if (status.status == 0) message = status.details + + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(10.dp)) { + val textColor = MaterialTheme.colorScheme.onSurface + Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp)) + Text(message, color = textColor) + Row(Modifier.padding(top = 10.dp)) { + Spacer(Modifier.weight(0.5f)) + Text(stringResource(R.string.copy_to_clipboard), color = textColor, + modifier = Modifier.clickable { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(requireContext().getString(R.string.download_error_details), message) + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < 32) EventFlow.postEvent(FlowEvent.MessageEvent(requireContext().getString(R.string.copied_to_clipboard))) + }) + Spacer(Modifier.weight(0.3f)) + Text("OK", color = textColor, modifier = Modifier.clickable { onDismissRequest() }) + Spacer(Modifier.weight(0.2f)) + } + } + } + } + } + } + + @Composable + fun SubscriptionDetailDialog(log: SubscriptionLog, showDialog: Boolean, onDismissRequest: () -> Unit) { + if (showDialog) { + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(10.dp)) { + val textColor = MaterialTheme.colorScheme.onSurface + Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp)) + Text(log.title, color = textColor) + Text(log.comment, color = textColor) + Text("URL: " + log.url, color = textColor) + Text("Link: " + log.link, color = textColor) + Row(Modifier.padding(top = 10.dp)) { + Spacer(Modifier.weight(0.3f)) + Text("OK", color = textColor, modifier = Modifier.clickable { onDismissRequest() }) + Spacer(Modifier.weight(0.2f)) + } + } + } + } + } + } + + @Composable + fun DownlaodDetailDialog(status: DownloadResult, showDialog: Boolean, onDismissRequest: () -> Unit) { + if (showDialog) { + var url = "unknown" + when (status.feedfileType) { + EpisodeMedia.FEEDFILETYPE_FEEDMEDIA -> { + val media = realm.query(EpisodeMedia::class).query("id == $0", status.feedfileId).first().find() + if (media != null) url = media.downloadUrl?:"" + } + Feed.FEEDFILETYPE_FEED -> { + val feed = getFeed(status.feedfileId, false) + if (feed != null) url = feed.downloadUrl?:"" + } + } + var message = requireContext().getString(R.string.download_successful) + if (!status.isSuccessful) message = status.reasonDetailed + val messageFull = requireContext().getString(R.string.download_log_details_message, requireContext().getString(from(status.reason)), message, url) + + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp), ) { + Column(modifier = Modifier.padding(10.dp)) { + val textColor = MaterialTheme.colorScheme.onSurface + Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp)) + Text(messageFull, color = textColor) + Row(Modifier.padding(top = 10.dp)) { + Spacer(Modifier.weight(0.5f)) + Text(stringResource(R.string.copy_to_clipboard), color = textColor, + modifier = Modifier.clickable { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(requireContext().getString(R.string.download_error_details), messageFull) + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < 32) + EventFlow.postEvent(FlowEvent.MessageEvent(requireContext().getString(R.string.copied_to_clipboard))) + }) + Spacer(Modifier.weight(0.3f)) + Text("OK", color = textColor, modifier = Modifier.clickable { onDismissRequest() }) + Spacer(Modifier.weight(0.2f)) + } + } + } + } + } + } + + companion object { + val TAG: String = LogsFragment::class.simpleName ?: "Anonymous" + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index fe27eb02..7850165f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -7,11 +7,8 @@ import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems import ac.mdiq.podcini.storage.database.Episodes.getEpisodesCount import ac.mdiq.podcini.storage.database.Feeds.getFeedCount import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.model.EpisodeFilter +import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.model.EpisodeFilter.Companion.unfiltered -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.model.PlayQueue -import ac.mdiq.podcini.storage.model.ShareLog import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.PreferenceActivity import ac.mdiq.podcini.ui.compose.CustomTheme @@ -224,8 +221,9 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { QueuesFragment.TAG to NavItem(QueuesFragment.TAG, R.drawable.ic_playlist_play, R.string.queue_label), AllEpisodesFragment.TAG to NavItem(AllEpisodesFragment.TAG, R.drawable.ic_feed, R.string.episodes_label), DownloadsFragment.TAG to NavItem(DownloadsFragment.TAG, R.drawable.ic_download, R.string.downloads_label), - HistoryFragment.TAG to NavItem(HistoryFragment.TAG, R.drawable.ic_history, R.string.playback_history_label), - SharedLogFragment.TAG to NavItem(SharedLogFragment.TAG, R.drawable.ic_share, R.string.shared_log_label), + HistoryFragment.TAG to NavItem(HistoryFragment.TAG, R.drawable.baseline_work_history_24, R.string.playback_history_label), + LogsFragment.TAG to NavItem(LogsFragment.TAG, R.drawable.ic_history, R.string.logs_label), +// SubscriptionLogFragment.TAG to NavItem(SubscriptionLogFragment.TAG, R.drawable.ic_subscriptions, R.string.subscriptions_log_label), StatisticsFragment.TAG to NavItem(StatisticsFragment.TAG, R.drawable.ic_chart_box, R.string.statistics_label), OnlineSearchFragment.TAG to NavItem(OnlineSearchFragment.TAG, R.drawable.ic_add, R.string.add_feed_label) ) @@ -256,7 +254,10 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { navMap[HistoryFragment.TAG]?.count = getNumberOfPlayed().toInt() navMap[DownloadsFragment.TAG]?.count = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name)) navMap[AllEpisodesFragment.TAG]?.count = numItems - navMap[SharedLogFragment.TAG]?.count = realm.query(ShareLog::class).count().find().toInt() + navMap[LogsFragment.TAG]?.count = realm.query(ShareLog::class).count().find().toInt() + + realm.query(SubscriptionLog::class).count().find().toInt() + + realm.query(DownloadResult::class).count().find().toInt() +// navMap[SubscriptionLogFragment.TAG]?.count = realm.query(SubscriptionLog::class).count().find().toInt() } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt index 57447001..e07d9798 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt @@ -13,11 +13,14 @@ import ac.mdiq.podcini.storage.database.Feeds.getFeed import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences import ac.mdiq.podcini.storage.model.* +import ac.mdiq.podcini.storage.model.Rating.Companion.fromCode +import ac.mdiq.podcini.storage.model.SubscriptionLog.Companion.feedLogsMap import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd +import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev import android.app.Dialog import android.content.Context import android.content.DialogInterface @@ -41,8 +44,10 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout @@ -58,6 +63,7 @@ import org.jsoup.Jsoup import org.jsoup.nodes.Document import java.io.File import java.io.IOException +import java.util.* import kotlin.concurrent.Volatile /** @@ -321,6 +327,7 @@ class OnlineFeedFragment : Fragment() { @Composable fun MainView() { val textColor = MaterialTheme.colorScheme.onSurface + val feedLogsMap_ = feedLogsMap!! ConstraintLayout(modifier = Modifier.fillMaxSize()) { val (progressBar, main) = createRefs() if (showProgress) CircularProgressIndicator(progress = { 0.6f }, @@ -404,9 +411,28 @@ class OnlineFeedFragment : Fragment() { } } Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) { - Text("$numEpisodes episodes", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 10.dp)) - Text(stringResource(R.string.description_label), color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp)) - Text(HtmlToPlainText.getPlainText(feed?.description?:""), color = textColor, style = MaterialTheme.typography.bodyMedium) + Text("$numEpisodes episodes", color = textColor, style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 5.dp, bottom = 10.dp)) + Text(stringResource(R.string.description_label), color = textColor, style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 5.dp, bottom = 4.dp)) + Text(HtmlToPlainText.getPlainText(feed?.description ?: ""), color = textColor, style = MaterialTheme.typography.bodyMedium) + val sLog = remember {feedLogsMap_[feed?.downloadUrl?:""] } + if (sLog != null) { + val commentTextState by remember { mutableStateOf(TextFieldValue(sLog?.comment ?: "")) } + val context = LocalContext.current + val cancelDate = remember { formatAbbrev(context, Date(sLog.cancelDate)) } + val ratingRes = remember { fromCode(sLog.rating).res } + if (!commentTextState.text.isEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 15.dp, top = 10.dp, bottom = 5.dp)) { + Text(stringResource(R.string.my_opinion_label), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium) + Icon(painter = painterResource(ratingRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = null, modifier = Modifier.padding(start = 5.dp)) + } + Text(commentTextState.text, color = textColor, style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 15.dp, bottom = 10.dp)) + Text(stringResource(R.string.cancelled_on_label) + ": " + cancelDate, color = textColor, style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 15.dp, bottom = 10.dp)) + } + } Text(feed?.mostRecentItem?.title ?: "", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp)) Text("${feed?.language?:""} ${feed?.type ?: ""} ${feed?.lastUpdate ?: ""}", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp)) Text(feed?.link?:"", color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 5.dp, bottom = 4.dp)) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index 80fb58ad..e0443429 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -503,7 +503,10 @@ import kotlin.math.max setContent { val showDialog = remember { mutableStateOf(true) } CustomTheme(requireContext()) { - RenameQueueDialog(showDialog = showDialog.value, onDismiss = { showDialog.value = false }) + RenameQueueDialog(showDialog = showDialog.value, onDismiss = { + showDialog.value = false + (view as? ViewGroup)?.removeView(this@apply) + }) } } } @@ -515,7 +518,10 @@ import kotlin.math.max setContent { val showDialog = remember { mutableStateOf(true) } CustomTheme(requireContext()) { - AddQueueDialog(showDialog = showDialog.value, onDismiss = { showDialog.value = false }) + AddQueueDialog(showDialog = showDialog.value, onDismiss = { + showDialog.value = false + (view as? ViewGroup)?.removeView(this@apply) + }) } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt index 968fc068..1b5a8be2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt @@ -5,7 +5,9 @@ import ac.mdiq.podcini.databinding.FragmentSearchResultsBinding import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult import ac.mdiq.podcini.net.feed.discovery.PodcastSearcher import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry +import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl import ac.mdiq.podcini.storage.database.Feeds.getFeedList +import ac.mdiq.podcini.storage.model.SubscriptionLog.Companion.getFeedLogMap import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.OnlineFeedItem @@ -51,6 +53,8 @@ class SearchResultsFragment : Fragment() { private var searchProvider: PodcastSearcher? = null + private val feedLogs = getFeedLogMap() + private var searchResults = mutableStateListOf() private var errorText by mutableStateOf("") private var retryQerry by mutableStateOf("") @@ -106,7 +110,11 @@ class SearchResultsFragment : Fragment() { }, verticalArrangement = Arrangement.spacedBy(8.dp)) { items(searchResults.size) { index -> - OnlineFeedItem(activity = activity as MainActivity, searchResults[index]) + val result = searchResults[index] + val urlPrepared by remember { mutableStateOf(prepareUrl(result.feedUrl!!)) } + val sLog = remember { mutableStateOf(feedLogs[urlPrepared]) } +// Logd(TAG, "result: ${result.feedUrl} ${feedLogs[urlPrepared]}") + OnlineFeedItem(activity = activity as MainActivity, result, sLog.value) } } if (searchResults.isEmpty()) Text(noResultText, color = textColor, modifier = Modifier.constrainAs(empty) { centerTo(parent) }) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SharedLogFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SharedLogFragment.kt deleted file mode 100644 index 162040c1..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SharedLogFragment.kt +++ /dev/null @@ -1,208 +0,0 @@ -package ac.mdiq.podcini.ui.fragment - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.SharedlogFragmentBinding -import ac.mdiq.podcini.storage.database.RealmDB.realm -import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope -import ac.mdiq.podcini.storage.model.ShareLog -import ac.mdiq.podcini.ui.activity.ShareReceiverActivity.Companion.receiveShared -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.compose.confirmAddYoutubeEpisode -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.MiscFormatter.formatDateTimeFlex -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.* -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.Toolbar -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import io.realm.kotlin.query.Sort -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.* - -class SharedLogFragment : Fragment(), Toolbar.OnMenuItemClickListener { - private var _binding: SharedlogFragmentBinding? = null - private val binding get() = _binding!! - - private val logs = mutableStateListOf() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - Logd(TAG, "fragment onCreateView") - _binding = SharedlogFragmentBinding.inflate(inflater) - binding.toolbar.inflateMenu(R.menu.download_log) - binding.toolbar.setOnMenuItemClickListener(this) - - binding.lazyColumn.setContent { - CustomTheme(requireContext()) { - MainView() - } - } - loadLog() - return binding.root - } - - override fun onDestroyView() { - Logd(TAG, "onDestroyView") - _binding = null - logs.clear() - super.onDestroyView() - } - - @Composable - fun MainView() { - val lazyListState = rememberLazyListState() - val showDialog = remember { mutableStateOf(false) } - val dialogParam = remember { mutableStateOf(ShareLog()) } - if (showDialog.value) { - DetailDialog(status = dialogParam.value, showDialog = showDialog.value, onDismissRequest = { showDialog.value = false }) - } - var showYTMediaConfirmDialog by remember { mutableStateOf(false) } - var sharedUrl by remember { mutableStateOf("") } - if (showYTMediaConfirmDialog) - confirmAddYoutubeEpisode(listOf(sharedUrl), showYTMediaConfirmDialog, onDismissRequest = { showYTMediaConfirmDialog = false }) - - LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 5.dp, bottom = 5.dp), - verticalArrangement = Arrangement.spacedBy(8.dp)) { - itemsIndexed(logs) { position, log -> - val textColor = MaterialTheme.colorScheme.onSurface - Row (modifier = Modifier.clickable { - if (log.status == 1) { - showDialog.value = true - dialogParam.value = log - } else { - receiveShared(log.url!!, activity as AppCompatActivity, false) { - sharedUrl = log.url!! - showYTMediaConfirmDialog = true - } - } - }) { - Column { - Row { - val icon = remember { if (log.status == 1) Icons.Filled.Info else Icons.Filled.Warning } - val iconColor = remember { if (log.status == 1) Color.Green else Color.Yellow } - Icon(icon, "Info", tint = iconColor, modifier = Modifier.padding(end = 2.dp)) - Text(formatDateTimeFlex(Date(log.id)), color = textColor) - Spacer(Modifier.weight(1f)) - var showAction by remember { mutableStateOf(log.status != 1) } - if (true || showAction) { - Icon(painter = painterResource(R.drawable.ic_delete), tint = textColor, contentDescription = null, - modifier = Modifier.width(25.dp).height(25.dp).clickable { - }) - } - } - Text(log.url?:"unknown", color = textColor) - val statusText = remember {"" } - Text(statusText, color = textColor) - if (log.status != 1) { - Text(log.details, color = Color.Red) - Text(stringResource(R.string.download_error_tap_for_details), color = textColor) - } - } - } - } - } - } - - @Deprecated("Deprecated in Java") - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.clear_logs_item).setVisible(logs.isNotEmpty()) - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - when { - super.onOptionsItemSelected(item) -> return true - item.itemId == R.id.clear_logs_item -> { - runOnIOScope { - realm.write { - val dlog = query(ShareLog::class).find() - delete(dlog) - } - loadLog() - } - } - else -> return false - } - return true - } - - private fun loadLog() { - lifecycleScope.launch { - try { - val result = withContext(Dispatchers.IO) { - Logd(TAG, "getDownloadLog() called") - realm.query(ShareLog::class).sort("id", Sort.DESCENDING).find().toMutableList() -// realm.copyFromRealm(dlog) - } - withContext(Dispatchers.Main) { - logs.clear() - logs.addAll(result) - } - } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) } - } - } - - @Composable - fun DetailDialog(status: ShareLog, showDialog: Boolean, onDismissRequest: () -> Unit) { - if (showDialog) { - var message = requireContext().getString(R.string.download_successful) - if (status.status == 0) message = status.details - - Dialog(onDismissRequest = { onDismissRequest() }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.padding(10.dp)) { - val textColor = MaterialTheme.colorScheme.onSurface - Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp)) - Text(message, color = textColor) - Row(Modifier.padding(top = 10.dp)) { - Spacer(Modifier.weight(0.5f)) - Text(stringResource(R.string.copy_to_clipboard), color = textColor, - modifier = Modifier.clickable { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(requireContext().getString(R.string.download_error_details), message) - clipboard.setPrimaryClip(clip) - if (Build.VERSION.SDK_INT < 32) EventFlow.postEvent(FlowEvent.MessageEvent(requireContext().getString(R.string.copied_to_clipboard))) - }) - Spacer(Modifier.weight(0.3f)) - Text("OK", color = textColor, modifier = Modifier.clickable { onDismissRequest() }) - Spacer(Modifier.weight(0.2f)) - } - } - } - } - } - } - - companion object { - val TAG: String = SharedLogFragment::class.simpleName ?: "Anonymous" - } -} 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 5cfd4a6e..a6796db7 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 @@ -3,11 +3,11 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.* import ac.mdiq.podcini.net.feed.FeedUpdateManager -import ac.mdiq.podcini.playback.base.VideoMode import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs import ac.mdiq.podcini.preferences.fragments.ImportExportPreferencesFragment.* +import ac.mdiq.podcini.storage.database.Feeds import ac.mdiq.podcini.storage.database.Feeds.createSynthetic import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getTags @@ -19,10 +19,10 @@ import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.FeedAutoDeleteOptions import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.compose.RemoveFeedDialog import ac.mdiq.podcini.ui.compose.Spinner import ac.mdiq.podcini.ui.dialog.CustomFeedNameDialog import ac.mdiq.podcini.ui.dialog.FeedSortDialog -import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog import ac.mdiq.podcini.ui.dialog.TagSettingsDialog import ac.mdiq.podcini.ui.fragment.FeedSettingsFragment.Companion.queueSettingOptions import ac.mdiq.podcini.ui.utils.EmptyViewHandler @@ -71,7 +71,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -282,7 +281,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { is FlowEvent.FeedsFilterEvent -> loadSubscriptions() is FlowEvent.EpisodePlayedEvent -> loadSubscriptions() is FlowEvent.FeedTagsChangedEvent -> loadSubscriptions() -// is FlowEvent.FeedPrefsChangeEvent -> onFeedPrefsChangeEvent(event) + is FlowEvent.FeedPrefsChangeEvent -> loadSubscriptions() else -> {} } } @@ -305,10 +304,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } -// private fun onFeedPrefsChangeEvent(event: FlowEvent.FeedPrefsChangeEvent) { -// val feed = getFeed(event.feed.id) -// } - @UnstableApi override fun onMenuItemClick(item: MenuItem): Boolean { val itemId = item.itemId when (itemId) { @@ -517,10 +512,17 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } + @kotlin.OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable - fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList, modifier: Modifier = Modifier) { - val TAG = "EpisodeSpeedDial ${selected.size}" - var isExpanded by remember { mutableStateOf(false) } + fun LazyList() { + var selectedSize by remember { mutableStateOf(0) } + val selected = remember { mutableStateListOf() } + var longPressIndex by remember { mutableIntStateOf(-1) } + var refreshing by remember { mutableStateOf(false)} + + var showRemoveFeedDialog by remember { mutableStateOf(false) } + if (showRemoveFeedDialog) RemoveFeedDialog(selected, onDismissRequest = {showRemoveFeedDialog = false}, null) + fun saveFeedPreferences(preferencesConsumer: Consumer) { for (feed in selected) { if (feed.preferences == null) continue @@ -531,286 +533,305 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } val numItems = selected.size - activity.showSnackbarAbovePlayer(activity.resources.getQuantityString(R.plurals.updated_feeds_batch_label, numItems, numItems), Snackbar.LENGTH_LONG) + (activity as MainActivity).showSnackbarAbovePlayer(activity!!.resources.getQuantityString(R.plurals.updated_feeds_batch_label, numItems, numItems), Snackbar.LENGTH_LONG) } - fun autoDeleteEpisodesPrefHandler() { - val composeView = ComposeView(activity).apply { - setContent { - val showDialog = remember { mutableStateOf(true) } - CustomTheme(activity) { - if (showDialog.value) { - val (selectedOption, _) = remember { mutableStateOf("") } - Dialog(onDismissRequest = { showDialog.value = false }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Column { - FeedAutoDeleteOptions.forEach { text -> - Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp) - .selectable(selected = (text == selectedOption), - onClick = { - if (text != selectedOption) { - val autoDeleteAction: AutoDeleteAction = AutoDeleteAction.fromTag(text) - saveFeedPreferences { it: FeedPreferences -> - it.autoDeleteAction = autoDeleteAction - } - showDialog.value = false - } - } - ), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton(selected = (text == selectedOption), onClick = { }) - Text(text = text, style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 16.dp)) + + @Composable + fun AutoDeleteHandlerDialog(onDismissRequest: () -> Unit) { + val (selectedOption, _) = remember { mutableStateOf("") } + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column { + FeedAutoDeleteOptions.forEach { text -> + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp) + .selectable(selected = (text == selectedOption), + onClick = { + if (text != selectedOption) { + val autoDeleteAction: AutoDeleteAction = AutoDeleteAction.fromTag(text) + saveFeedPreferences { it: FeedPreferences -> + it.autoDeleteAction = autoDeleteAction } + onDismissRequest() } } - } + ), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = (text == selectedOption), onClick = { }) + Text(text = text, style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 16.dp)) } } } } } } - (activity.window.decorView as ViewGroup).addView(composeView) } - fun associatedQueuePrefHandler() { - val composeView = ComposeView(activity).apply { - setContent { - val showDialog = remember { mutableStateOf(true) } - CustomTheme(activity) { - var selectedOption by remember {mutableStateOf("")} - if (showDialog.value) { - Dialog(onDismissRequest = { showDialog.value = false }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - queueSettingOptions.forEach { option -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = option == selectedOption, - onCheckedChange = { isChecked -> - selectedOption = option - if (isChecked) Logd(Companion.TAG, "$option is checked") - when (selectedOption) { - "Default" -> { - saveFeedPreferences { it: FeedPreferences -> it.queueId = 0L } - showDialog.value = false - } - "Active" -> { - saveFeedPreferences { it: FeedPreferences -> it.queueId = -1L } - showDialog.value = false - } - "None" -> { - saveFeedPreferences { it: FeedPreferences -> it.queueId = -2L } - showDialog.value = false - } - "Custom" -> {} - } - } - ) - Text(option) + + @Composable + fun SetAssociateQueueDialog(onDismissRequest: () -> Unit) { + var selectedOption by remember {mutableStateOf("")} + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + queueSettingOptions.forEach { option -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = option == selectedOption, + onCheckedChange = { isChecked -> + selectedOption = option + if (isChecked) Logd(TAG, "$option is checked") + when (selectedOption) { + "Default" -> { + saveFeedPreferences { it: FeedPreferences -> it.queueId = 0L } + onDismissRequest() } - } - if (selectedOption == "Custom") { - val queues = realm.query(PlayQueue::class).find() - Spinner(items = queues.map { it.name }, selectedItem = "Default") { name -> - Logd(Companion.TAG, "Queue selected: $name") - val q = queues.firstOrNull { it.name == name } - if (q != null) { - saveFeedPreferences { it: FeedPreferences -> it.queueId = q.id } - showDialog.value = false - } + "Active" -> { + saveFeedPreferences { it: FeedPreferences -> it.queueId = -1L } + onDismissRequest() + } + "None" -> { + saveFeedPreferences { it: FeedPreferences -> it.queueId = -2L } + onDismissRequest() } + "Custom" -> {} } } + ) + Text(option) + } + } + if (selectedOption == "Custom") { + val queues = realm.query(PlayQueue::class).find() + Spinner(items = queues.map { it.name }, selectedItem = "Default") { name -> + Logd(TAG, "Queue selected: $name") + val q = queues.firstOrNull { it.name == name } + if (q != null) { + saveFeedPreferences { it: FeedPreferences -> it.queueId = q.id } + onDismissRequest() } } } } } } - (activity.window.decorView as ViewGroup).addView(composeView) } - val options = listOf<@Composable () -> Unit>( - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "ic_delete: ${selected.size}") - RemoveFeedDialog.show(activity, selected) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "") - Text(stringResource(id = R.string.remove_feed_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "ic_refresh: ${selected.size}") - val composeView = ComposeView(activity).apply { - setContent { - val showDialog = remember { mutableStateOf(true) } - CustomTheme(activity) { - if (showDialog.value) { - Dialog(onDismissRequest = { showDialog.value = false }) { - Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) { - Row(Modifier.fillMaxWidth()) { - Icon(ImageVector.vectorResource(id = R.drawable.ic_refresh), "") - Spacer(modifier = Modifier.width(20.dp)) - Text(text = stringResource(R.string.keep_updated), style = MaterialTheme.typography.titleLarge) - Spacer(modifier = Modifier.weight(1f)) - var checked by remember { mutableStateOf(false) } - Switch(checked = checked, - onCheckedChange = { - checked = it - saveFeedPreferences { pref: FeedPreferences -> - pref.keepUpdated = checked - } - } - ) - } - Text(text = stringResource(R.string.keep_updated_summary), style = MaterialTheme.typography.bodyMedium) - } - } + + @Composable + fun SetKeepUpdateDialog(onDismissRequest: () -> Unit) { + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) { + Row(Modifier.fillMaxWidth()) { + Icon(ImageVector.vectorResource(id = R.drawable.ic_refresh), "") + Spacer(modifier = Modifier.width(20.dp)) + Text(text = stringResource(R.string.keep_updated), style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.weight(1f)) + var checked by remember { mutableStateOf(false) } + Switch(checked = checked, + onCheckedChange = { + checked = it + saveFeedPreferences { pref: FeedPreferences -> + pref.keepUpdated = checked } } - } + ) } + Text(text = stringResource(R.string.keep_updated_summary), style = MaterialTheme.typography.bodyMedium) } - (activity.window.decorView as ViewGroup).addView(composeView) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_refresh), "") - Text(stringResource(id = R.string.keep_updated)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "ic_download: ${selected.size}") - val preferenceSwitchDialog = PreferenceSwitchDialog(activity, activity.getString(R.string.auto_download_settings_label), activity.getString(R.string.auto_download_label)) - preferenceSwitchDialog.setOnPreferenceChangedListener(@UnstableApi object: PreferenceSwitchDialog.OnPreferenceChangedListener { - override fun preferenceChanged(enabled: Boolean) { - saveFeedPreferences { it: FeedPreferences -> it.autoDownload = enabled } + } + } + } + + @Composable + fun ChooseRatingDialog(selected: List, onDismissRequest: () -> Unit) { + Dialog(onDismissRequest = onDismissRequest) { + Surface(shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + for (rating in Rating.entries) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable { + for (item in selected) Feeds.setRating(item, rating.code) + onDismissRequest() + }) { + Icon(imageVector = ImageVector.vectorResource(id = rating.res), "") + Text(rating.name, Modifier.padding(start = 4.dp)) + } } - }) - preferenceSwitchDialog.openDialog() - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "") - Text(stringResource(id = R.string.auto_download_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "ic_delete_auto: ${selected.size}") - autoDeleteEpisodesPrefHandler() - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete_auto), "") - Text(stringResource(id = R.string.auto_delete_label)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "ic_playback_speed: ${selected.size}") - val vBinding = PlaybackSpeedFeedSettingDialogBinding.inflate(activity.layoutInflater) - vBinding.seekBar.setProgressChangedListener { speed: Float? -> - vBinding.currentSpeedLabel.text = String.format(Locale.getDefault(), "%.2fx", speed) - } - vBinding.useGlobalCheckbox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> - vBinding.seekBar.isEnabled = !isChecked - vBinding.seekBar.alpha = if (isChecked) 0.4f else 1f - vBinding.currentSpeedLabel.alpha = if (isChecked) 0.4f else 1f } - vBinding.seekBar.updateSpeed(1.0f) - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.playback_speed) - .setView(vBinding.root) - .setPositiveButton("OK") { _: DialogInterface?, _: Int -> - val newSpeed = if (vBinding.useGlobalCheckbox.isChecked) FeedPreferences.SPEED_USE_GLOBAL - else vBinding.seekBar.currentSpeed - saveFeedPreferences { it: FeedPreferences -> - it.playSpeed = newSpeed + } + } + } + + var showChooseRatingDialog by remember { mutableStateOf(false) } + if (showChooseRatingDialog) ChooseRatingDialog(selected) { showChooseRatingDialog = false } + + var showAutoDeleteHandlerDialog by remember { mutableStateOf(false) } + if (showAutoDeleteHandlerDialog) AutoDeleteHandlerDialog {showAutoDeleteHandlerDialog = false} + + var showAssociateDialog by remember { mutableStateOf(false) } + if (showAssociateDialog) SetAssociateQueueDialog {showAssociateDialog = false} + + var showKeepUpdateDialog by remember { mutableStateOf(false) } + if (showKeepUpdateDialog) SetKeepUpdateDialog {showKeepUpdateDialog = false} + + @Composable + fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList, modifier: Modifier = Modifier) { + val TAG = "EpisodeSpeedDial ${selected.size}" + var isExpanded by remember { mutableStateOf(false) } + val options = listOf<@Composable () -> Unit>( + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + showRemoveFeedDialog = true + isExpanded = false + selectMode = false + Logd(TAG, "ic_delete: ${selected.size}") +// RemoveFeedDialog.show(activity, selected) + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "") + Text(stringResource(id = R.string.remove_feed_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + showKeepUpdateDialog = true + isExpanded = false + selectMode = false + Logd(TAG, "ic_refresh: ${selected.size}") + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_refresh), "") + Text(stringResource(id = R.string.keep_updated)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + selectMode = false + Logd(TAG, "ic_download: ${selected.size}") + val preferenceSwitchDialog = PreferenceSwitchDialog(activity, activity.getString(R.string.auto_download_settings_label), activity.getString(R.string.auto_download_label)) + preferenceSwitchDialog.setOnPreferenceChangedListener(@UnstableApi object: PreferenceSwitchDialog.OnPreferenceChangedListener { + override fun preferenceChanged(enabled: Boolean) { + saveFeedPreferences { it: FeedPreferences -> it.autoDownload = enabled } } + }) + preferenceSwitchDialog.openDialog() + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "") + Text(stringResource(id = R.string.auto_download_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + showAutoDeleteHandlerDialog = true + isExpanded = false + selectMode = false + Logd(TAG, "ic_delete_auto: ${selected.size}") +// autoDeleteEpisodesPrefHandler() + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete_auto), "") + Text(stringResource(id = R.string.auto_delete_label)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + selectMode = false + Logd(TAG, "ic_playback_speed: ${selected.size}") + val vBinding = PlaybackSpeedFeedSettingDialogBinding.inflate(activity.layoutInflater) + vBinding.seekBar.setProgressChangedListener { speed: Float? -> + vBinding.currentSpeedLabel.text = String.format(Locale.getDefault(), "%.2fx", speed) } - .setNegativeButton(R.string.cancel_label, null) - .show() - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playback_speed), "") - Text(stringResource(id = R.string.playback_speed)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "ic_tag: ${selected.size}") - TagSettingsDialog.newInstance(selected).show(activity.supportFragmentManager, Companion.TAG) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_tag), "") - Text(stringResource(id = R.string.edit_tags)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "ic_playlist_play: ${selected.size}") - associatedQueuePrefHandler() - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") - Text(stringResource(id = R.string.pref_feed_associated_queue)) - } }, - { Row(modifier = Modifier.padding(horizontal = 16.dp) - .clickable { - isExpanded = false - selectMode = false - Logd(TAG, "baseline_import_export_24: ${selected.size}") - val exportType = Export.OPML_SELECTED - val title = String.format(exportType.outputNameTemplate, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) - val intentPickAction = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(exportType.contentType) - .putExtra(Intent.EXTRA_TITLE, title) - try { - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult - val uri = result.data!!.data - exportOPML(uri, selected) - }.launch(intentPickAction) - return@clickable - } catch (e: ActivityNotFoundException) { Log.e(Companion.TAG, "No activity found. Should never happen...") } - // if on SDK lower than API 21 or the implicit intent failed, fallback to the legacy export process - exportOPML(null, selected) - }, verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = ImageVector.vectorResource(id = R.drawable.baseline_import_export_24), "") - Text(stringResource(id = R.string.opml_export_label)) - } }, - ) - val scrollState = rememberScrollState() - Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) { - if (isExpanded) options.forEachIndexed { _, button -> - FloatingActionButton(modifier = Modifier.padding(start = 4.dp, bottom = 6.dp).height(40.dp), containerColor = Color.LightGray, onClick = {}) { button() } + vBinding.useGlobalCheckbox.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + vBinding.seekBar.isEnabled = !isChecked + vBinding.seekBar.alpha = if (isChecked) 0.4f else 1f + vBinding.currentSpeedLabel.alpha = if (isChecked) 0.4f else 1f + } + vBinding.seekBar.updateSpeed(1.0f) + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.playback_speed) + .setView(vBinding.root) + .setPositiveButton("OK") { _: DialogInterface?, _: Int -> + val newSpeed = if (vBinding.useGlobalCheckbox.isChecked) FeedPreferences.SPEED_USE_GLOBAL + else vBinding.seekBar.currentSpeed + saveFeedPreferences { it: FeedPreferences -> + it.playSpeed = newSpeed + } + } + .setNegativeButton(R.string.cancel_label, null) + .show() + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playback_speed), "") + Text(stringResource(id = R.string.playback_speed)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + selectMode = false + Logd(TAG, "ic_tag: ${selected.size}") + TagSettingsDialog.newInstance(selected).show(activity.supportFragmentManager, Companion.TAG) + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_tag), "") + Text(stringResource(id = R.string.edit_tags)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + showAssociateDialog = true + isExpanded = false + selectMode = false + Logd(TAG, "ic_playlist_play: ${selected.size}") +// associatedQueuePrefHandler() + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") + Text(stringResource(id = R.string.pref_feed_associated_queue)) + } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + selectMode = false + Logd(TAG, "ic_star: ${selected.size}") + showChooseRatingDialog = true + isExpanded = false + }, verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "Set rating") + Text(stringResource(id = R.string.set_rating_label)) } }, + { Row(modifier = Modifier.padding(horizontal = 16.dp) + .clickable { + isExpanded = false + selectMode = false + Logd(TAG, "baseline_import_export_24: ${selected.size}") + val exportType = Export.OPML_SELECTED + val title = String.format(exportType.outputNameTemplate, SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())) + val intentPickAction = Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(exportType.contentType) + .putExtra(Intent.EXTRA_TITLE, title) + try { + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode != RESULT_OK || result.data == null) return@registerForActivityResult + val uri = result.data!!.data + exportOPML(uri, selected) + }.launch(intentPickAction) + return@clickable + } catch (e: ActivityNotFoundException) { Log.e(Companion.TAG, "No activity found. Should never happen...") } + // if on SDK lower than API 21 or the implicit intent failed, fallback to the legacy export process + exportOPML(null, selected) + }, verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = ImageVector.vectorResource(id = R.drawable.baseline_import_export_24), "") + Text(stringResource(id = R.string.opml_export_label)) + } }, + ) + val scrollState = rememberScrollState() + Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) { + if (isExpanded) options.forEachIndexed { _, button -> + FloatingActionButton(modifier = Modifier.padding(start = 4.dp, bottom = 6.dp).height(40.dp), containerColor = Color.LightGray, onClick = {}) { button() } + } + FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.secondary, + onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } } - FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.secondary, - onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } } - } - @kotlin.OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) - @Composable - fun LazyList() { - var selectedSize by remember { mutableStateOf(0) } - val selected = remember { mutableStateListOf() } - var longPressIndex by remember { mutableIntStateOf(-1) } - var refreshing by remember { mutableStateOf(false)} -// val coroutineScope = rememberCoroutineScope() PullToRefreshBox(modifier = Modifier.fillMaxWidth(), isRefreshing = refreshing, indicator = {}, onRefresh = { // coroutineScope.launch { refreshing = true @@ -820,7 +841,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { }) { if (if (useGrid == null) useGridLayout else useGrid!!) { val lazyGridState = rememberLazyGridState() - LazyVerticalGrid(state = lazyGridState, columns = GridCells.Fixed(3), + LazyVerticalGrid(state = lazyGridState, columns = GridCells.Adaptive(80.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(start = 12.dp, top = 16.dp, end = 12.dp, bottom = 16.dp) ) { @@ -846,6 +867,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (!feed.isBuilding) { selectMode = !selectMode isSelected = selectMode + selected.clear() if (selectMode) { selected.add(feed) longPressIndex = index @@ -857,29 +879,35 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { Logd(TAG, "long clicked: ${feed.title}") })) { val textColor = MaterialTheme.colorScheme.onSurface - ConstraintLayout { - val (coverImage, episodeCount, error) = createRefs() + ConstraintLayout(Modifier.fillMaxSize()) { + val (coverImage, episodeCount, rating, error) = createRefs() AsyncImage(model = feed.imageUrl, contentDescription = "coverImage", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher), - modifier = Modifier + modifier = Modifier.fillMaxWidth().aspectRatio(1f) .constrainAs(coverImage) { top.linkTo(parent.top) bottom.linkTo(parent.bottom) start.linkTo(parent.start) }) - Text(NumberFormat.getInstance().format(feed.episodes.size.toLong()), - modifier = Modifier.constrainAs(episodeCount) { + Text(NumberFormat.getInstance().format(feed.episodes.size.toLong()), color = Color.Green, + modifier = Modifier.background(Color.Gray).constrainAs(episodeCount) { end.linkTo(parent.end) top.linkTo(coverImage.top) }) + if (feed.rating != Rating.UNRATED.code) + Icon(painter = painterResource(Rating.fromCode(feed.rating).res), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).constrainAs(rating) { + start.linkTo(parent.start) + centerVerticallyTo(coverImage) + }) // TODO: need to use state if (feed.lastUpdateFailed) Icon(painter = painterResource(R.drawable.ic_error), tint = Color.Red, contentDescription = "error", - modifier = Modifier.constrainAs(error) { + modifier = Modifier.background(Color.Gray).constrainAs(error) { end.linkTo(parent.end) bottom.linkTo(coverImage.bottom) }) } - Text(feed.title ?: "No title", color = textColor, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(feed.title ?: "No title", color = textColor, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) } } } @@ -899,16 +927,33 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { Logd(TAG, "toggleSelected: selected: ${selected.size}") } Row(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)) { - AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher), - modifier = Modifier.width(80.dp).height(80.dp) - .clickable(onClick = { - Logd(TAG, "icon clicked!") - if (!feed.isBuilding) { - if (selectMode) toggleSelected() - else (activity as MainActivity).loadChildFragment(FeedInfoFragment.newInstance(feed)) - } - }) - ) + ConstraintLayout { + val (coverImage, rating) = createRefs() + AsyncImage(model = feed.imageUrl, + contentDescription = "imgvCover", + placeholder = painterResource(R.mipmap.ic_launcher), + error = painterResource(R.mipmap.ic_launcher), + modifier = Modifier.width(80.dp).height(80.dp) + .constrainAs(coverImage) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + }.clickable(onClick = { + Logd(TAG, "icon clicked!") + if (!feed.isBuilding) { + if (selectMode) toggleSelected() + else (activity as MainActivity).loadChildFragment(FeedInfoFragment.newInstance(feed)) + } + }) + ) + if (feed.rating != Rating.UNRATED.code) + Icon(painter = painterResource(Rating.fromCode(feed.rating).res), tint = MaterialTheme.colorScheme.tertiary, + contentDescription = "rating", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).constrainAs(rating) { + start.linkTo(parent.start) + centerVerticallyTo(coverImage) + }) + } val textColor = MaterialTheme.colorScheme.onSurface Column(Modifier.weight(1f).padding(start = 10.dp).combinedClickable(onClick = { Logd(TAG, "clicked: ${feed.title}") @@ -920,11 +965,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (!feed.isBuilding) { selectMode = !selectMode isSelected = selectMode + selected.clear() if (selectMode) { selected.add(feed) longPressIndex = index } else { - selected.clear() selectedSize = 0 longPressIndex = -1 } @@ -979,9 +1024,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (selectedSize != feedListFiltered.size) { selected.clear() selected.addAll(feedListFiltered) -// for (e in feedListFiltered) { -// selected.add(e) -// } selectAllRes = R.drawable.ic_select_none } else { selected.clear() 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 92c26810..0ecff408 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/FlowEvent.kt @@ -2,10 +2,7 @@ package ac.mdiq.podcini.util import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.net.download.DownloadStatus -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeSortOrder -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.model.Playable +import ac.mdiq.podcini.storage.model.* import android.content.Context import android.view.KeyEvent import androidx.core.util.Consumer @@ -168,7 +165,7 @@ sealed class FlowEvent { // TODO: need better handling at receving end data class EpisodePlayedEvent(val episode: Episode? = null) : FlowEvent() - data class RatingEvent(val episode: Episode, val rating: Int = Episode.Rating.FAVORITE.code) : FlowEvent() + data class RatingEvent(val episode: Episode, val rating: Int = Rating.FAVORITE.code) : FlowEvent() data class AllEpisodesFilterEvent(val filterValues: Set?) : FlowEvent() diff --git a/app/src/main/res/drawable/baseline_clear_24.xml b/app/src/main/res/drawable/baseline_clear_24.xml new file mode 100644 index 00000000..f8ca0c64 --- /dev/null +++ b/app/src/main/res/drawable/baseline_clear_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_sentiment_neutral_24.xml b/app/src/main/res/drawable/baseline_sentiment_neutral_24.xml new file mode 100644 index 00000000..bb5b8545 --- /dev/null +++ b/app/src/main/res/drawable/baseline_sentiment_neutral_24.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/baseline_work_history_24.xml b/app/src/main/res/drawable/baseline_work_history_24.xml new file mode 100644 index 00000000..9f5df201 --- /dev/null +++ b/app/src/main/res/drawable/baseline_work_history_24.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/layout/downloadlog_fragment.xml b/app/src/main/res/layout/downloadlog_fragment.xml deleted file mode 100644 index cae8bfa1..00000000 --- a/app/src/main/res/layout/downloadlog_fragment.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/feed_item_list_fragment.xml b/app/src/main/res/layout/feed_item_list_fragment.xml index 68d28252..9b880975 100644 --- a/app/src/main/res/layout/feed_item_list_fragment.xml +++ b/app/src/main/res/layout/feed_item_list_fragment.xml @@ -20,10 +20,10 @@ app:navigationIcon="?homeAsUpIndicator" app:navigationContentDescription="@string/toolbar_back_button_content_description"/> - + + + + diff --git a/app/src/main/res/layout/horizontal_feed_item.xml b/app/src/main/res/layout/horizontal_feed_item.xml index d717208d..22edf457 100644 --- a/app/src/main/res/layout/horizontal_feed_item.xml +++ b/app/src/main/res/layout/horizontal_feed_item.xml @@ -5,6 +5,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="96dp" + android:orientation="vertical" android:id="@+id/horizontal_feed_item" android:padding="4dp" android:clipToPadding="false" diff --git a/app/src/main/res/layout/sharedlog_fragment.xml b/app/src/main/res/layout/logs_fragment.xml similarity index 94% rename from app/src/main/res/layout/sharedlog_fragment.xml rename to app/src/main/res/layout/logs_fragment.xml index e0648267..8d2f2359 100644 --- a/app/src/main/res/layout/sharedlog_fragment.xml +++ b/app/src/main/res/layout/logs_fragment.xml @@ -13,7 +13,7 @@ android:layout_height="wrap_content" android:minHeight="?android:attr/actionBarSize" android:theme="?attr/actionBarTheme" - app:title="@string/shared_log_label" /> + app:title="@string/logs_label" /> - diff --git a/app/src/main/res/menu/downloads_completed.xml b/app/src/main/res/menu/downloads_completed.xml index 47d41828..ac34b7a9 100644 --- a/app/src/main/res/menu/downloads_completed.xml +++ b/app/src/main/res/menu/downloads_completed.xml @@ -10,11 +10,11 @@ android:title="@string/search_label" custom:showAsAction="always" /> - + + + + + @@ -25,13 +25,14 @@ + custom:showAsAction="always"/> + android:icon="@drawable/arrows_sort" + android:title="@string/sort" + custom:showAsAction="always"/> + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88eed65b..484e4eca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,7 +16,8 @@ Favorites Settings Downloads - Shared log + Logs + Subscriptions log Open settings Download log Subscriptions @@ -101,6 +102,10 @@ Share URL Go to this position + Show shared logs + Show subscription logs + Show download logs + Clear history This will clear the entire playback history. Are you sure you want to proceed\? @@ -213,6 +218,7 @@ Please confirm that you want to remove the selected podcasts, ALL their episodes (including downloaded episodes), and its statistics. Please confirm that you want to remove the podcast \"%1$s\" and its statistics. The files in the local source folder will not be deleted. Removing podcast + For future reference, you can record a reason here: Refresh complete podcast Multi select Select all above @@ -256,6 +262,7 @@ JavaScript No action My opinion + Cancelled on Removed from inbox Mark as played diff --git a/changelog.md b/changelog.md index 39f56761..40996036 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,25 @@ +# 6.11.0 + +* added SubscriptionLog to record unsubscribe history +* in online SearchResults, if an item has been subscribed but removed, a X mark appears on the cover image, +* in OnlineFeed, added prior rating, opinion and cancelled date on a feed previously unsubscribed +* renamed SharedLog fragment to LogsFragment and merged shared, subscription and download logs into the fragment + * the count of LogsFragment on NavDrawer is the sum of the three logs +* added Unrated to the rating system and set episodes default rating to Unrated +* added the same rating system to podcast/subscription/feed +* added comment/opinion to podcast/subscription/feed +* in FeedInfo, added rating telltale in the header and "My opinion" section under the Description text +* in Subscriptions view, added rating on the icon of every podcast +* in Subscriptions view, added set rating in multi-selection menu +* in RemoveFeedDialog, added delete reason text input for SubscriptionLog +* added Combo swipe action with ability to choose any specific action +* changed shift rating action to set ration action with a popup menu +* in Subscriptions grid view is set adaptive with min size of 80 with equal image size +* on the header of FeedInfo and FeedEpisodes, added background image and removed the dark bar +* in EpisodeInfo, show current status with telltale icons of played and inQueue (rather than reversed in prior version) +* various minor fixes with selections +* DownloadLog fragment removed + # 6.10.0 * in Subscriptions, added menu items to create normal or Youtube synthetic feeds for better organization diff --git a/fastlane/metadata/android/en-US/changelogs/3020270.txt b/fastlane/metadata/android/en-US/changelogs/3020270.txt new file mode 100644 index 00000000..e2c12437 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020270.txt @@ -0,0 +1,21 @@ + Version 6.11.0 + +* added SubscriptionLog to record unsubscribe history +* in online SearchResults, if an item has been subscribed but removed, a X mark appears on the cover image, +* in OnlineFeed, added prior rating, opinion and cancelled date on a feed previously unsubscribed +* renamed SharedLog fragment to LogsFragment and merged shared, subscription and download logs into the fragment + * the count of LogsFragment on NavDrawer is the sum of the three logs +* added Unrated to the rating system and set episodes default rating to Unrated +* added the same rating system to podcast/subscription/feed +* added comment/opinion to podcast/subscription/feed +* in FeedInfo, added rating telltale in the header and "My opinion" section under the Description text +* in Subscriptions view, added rating on the icon of every podcast +* in Subscriptions view, added set rating in multi-selection menu +* in RemoveFeedDialog, added delete reason text input for SubscriptionLog +* added Combo swipe action with ability to choose any specific action +* changed shift rating action to set ration action with a popup menu +* in Subscriptions grid view is set adaptive with min size of 80 with equal image size +* on the header of FeedInfo and FeedEpisodes, added background image and removed the dark bar +* in EpisodeInfo, show current status with telltale icons of played and inQueue (rather than reversed in prior version) +* various minor fixes with selections +* DownloadLog fragment removed diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 3caef284..d1579316 100755 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -A feature-rich open-source podcast instrument capable of handling Youtube and YT Music contents +Modern, feature-rich, capable of handling Youtube and YT Music contents