From 62a4b914ac63d1625866c225b49fcd7407fc25d6 Mon Sep 17 00:00:00 2001 From: Uwe Date: Wed, 13 May 2026 15:51:15 +0200 Subject: [PATCH 001/104] Docs: note release 2025.2.16 for Android 5 (cherry picked from commit aa5dadeaa28829d13780f2528e3413605a4c69a8) --- docs/legacy-versions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/legacy-versions.md b/docs/legacy-versions.md index 3de5767d48..737ea1582d 100644 --- a/docs/legacy-versions.md +++ b/docs/legacy-versions.md @@ -5,7 +5,7 @@ you really need them to. ## Android 5.0, 5.1 -**Latest version:** 2025.2.14 (2026-02-12) +**Latest version:** 2025.2.16 (2026-05-08) **Available on Google Play:** yes @@ -13,7 +13,7 @@ you really need them to. **Status:** everything is known to work. -[Get the APK](https://github.com/UweTrottmann/SeriesGuide/releases/tag/v2025.2.14) +[Get the APK](https://github.com/UweTrottmann/SeriesGuide/releases/tag/v2025.2.16) ## Android 4.4, 4.3, 4.2, 4.1 From 15516184da576c40d1c724850e39f406b9f6f3b8 Mon Sep 17 00:00:00 2001 From: Uwe Date: Wed, 13 May 2026 15:52:42 +0200 Subject: [PATCH 002/104] Changelog: fix heading for latest release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a9dc9b40..5fb161a83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Releases marked with 🧪 (or previously with the "beta" suffix) were released o * 📝 Trakt: support server changes to correctly fetch all collection and watchlist items. -## 2026.1.4 - 2026-05-08 +### 2026.1.4 - 2026-05-08 * 🔧 Trakt: prepare for server changes to correctly fetch all watched items. * 🔧 Trakt: improve log messages when a Trakt job fails. From 28f6121c5a608c325e238bd6f87c859d0cfc16b8 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 14 May 2026 07:01:54 +0200 Subject: [PATCH 003/104] SgShow2: note that small poster is still used due to legacy data --- .../seriesguide/provider/SeriesGuideContract.java | 2 +- .../com/battlelancer/seriesguide/shows/database/SgShow2.kt | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java b/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java index 340b0e8a11..98ecc68d84 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java +++ b/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java @@ -488,7 +488,7 @@ public interface SgShow2Columns extends BaseColumns { */ String POSTER = "series_poster"; /** - * Path to a small variant of the poster. Needs to be prefixed with the poster server URL. + * See {@link SgShow2#getPosterSmall()}. */ String POSTER_SMALL = "series_poster_small"; diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/database/SgShow2.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/database/SgShow2.kt index 4df4eb90d2..fabaa9d601 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/database/SgShow2.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/database/SgShow2.kt @@ -189,6 +189,11 @@ data class SgShow2( * A poster path. Needs to be prefixed with the poster server URL. */ @ColumnInfo(name = POSTER) val poster: String? = "", + /** + * For shows using TMDB data, the same as [poster]. + * + * For shows using legacy TVDB data, path to a small variant of the poster. + */ @ColumnInfo(name = POSTER_SMALL) val posterSmall: String? = "", @ColumnInfo(name = NEXTAIRDATEMS) val nextAirdateMs: Long? = NextEpisodeUpdater.UNKNOWN_NEXT_RELEASE_DATE, @ColumnInfo(name = NEXTTEXT) val nextText: String? = "", From 0356284589c582585ba643e6418518c6a58d1af1 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 7 May 2026 15:14:26 +0200 Subject: [PATCH 004/104] Lists: extract item UI model --- .../seriesguide/lists/SgListFragment.kt | 17 +- .../seriesguide/lists/SgListItemAdapter.kt | 174 ++----------- .../seriesguide/lists/SgListItemViewModel.kt | 9 +- .../seriesguide/lists/UiListItem.kt | 228 ++++++++++++++++++ 4 files changed, 261 insertions(+), 167 deletions(-) create mode 100644 app/src/main/java/com/battlelancer/seriesguide/lists/UiListItem.kt diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt index 5234889e7e..e327f91470 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Copyright 2012-2024 Uwe Trottmann +// SPDX-FileCopyrightText: Copyright © 2012 Uwe Trottmann package com.battlelancer.seriesguide.lists @@ -18,7 +18,6 @@ import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.databinding.FragmentListBinding import com.battlelancer.seriesguide.lists.ListsDistillationSettings.ListsSortOrderChangedEvent -import com.battlelancer.seriesguide.lists.database.SgListItemWithDetails import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItemTypes import com.battlelancer.seriesguide.shows.episodes.EpisodeTools import com.battlelancer.seriesguide.shows.tools.ShowSync @@ -111,25 +110,23 @@ class SgListFragment : Fragment() { private val itemClickListener: SgListItemViewHolder.ItemClickListener = object : SgListItemViewHolder.ItemClickListener { - override fun onItemClick(anchor: View, item: SgListItemWithDetails) { + override fun onItemClick(anchor: View, item: UiListItem) { requireActivity().startActivityWithAnimation( OverviewActivity.intentShow(requireActivity(), item.showId), anchor ) } - override fun onMoreOptionsClick(anchor: View, item: SgListItemWithDetails) { + override fun onMoreOptionsClick(anchor: View, item: UiListItem) { val popupMenu = PopupMenu(anchor.context, anchor) popupMenu.inflate(R.menu.lists_popup_menu) val menu = popupMenu.menu // Hide some options that only make sense for shows (for legacy non-show items) - val isShow = - item.type == ListItemTypes.TMDB_SHOW || item.type == ListItemTypes.TVDB_SHOW - menu.findItem(R.id.menu_action_lists_watched_next).isVisible = isShow + menu.findItem(R.id.menu_action_lists_watched_next).isVisible = item.isShow menu.findItem(R.id.menu_action_lists_favorites_add).isVisible = - isShow && !item.favorite + item.isShow && !item.isFavorite menu.findItem(R.id.menu_action_lists_favorites_remove).isVisible = - isShow && item.favorite + item.isShow && item.isFavorite popupMenu.setOnMenuItemClickListener( PopupMenuItemClickListener( requireContext(), parentFragmentManager, @@ -143,7 +140,7 @@ class SgListFragment : Fragment() { popupMenu.show() } - override fun onSetWatchedClick(item: SgListItemWithDetails) { + override fun onSetWatchedClick(item: UiListItem) { EpisodeTools.episodeWatchedIfNotZero(requireContext(), item.nextEpisodeId) } } diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemAdapter.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemAdapter.kt index a682820b42..636aff72f5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemAdapter.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemAdapter.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Copyright 2021-2024 Uwe Trottmann +// SPDX-FileCopyrightText: Copyright © 2021 Uwe Trottmann package com.battlelancer.seriesguide.lists @@ -8,30 +8,17 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat -import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.databinding.ItemShowListBinding -import com.battlelancer.seriesguide.lists.database.SgListItemWithDetails -import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItemTypes -import com.battlelancer.seriesguide.provider.SgRoomDatabase -import com.battlelancer.seriesguide.settings.DisplaySettings -import com.battlelancer.seriesguide.shows.overview.SeasonTools -import com.battlelancer.seriesguide.shows.tools.ShowStatus import com.battlelancer.seriesguide.util.ImageTools -import com.battlelancer.seriesguide.util.TextTools -import com.battlelancer.seriesguide.util.TimeTools -import com.battlelancer.seriesguide.util.TimeTools.formatWithDeviceZoneToDayAndTime import com.battlelancer.seriesguide.util.ViewTools.setContextAndLongClickListener -import org.threeten.bp.Instant class SgListItemAdapter( private val context: Context, private val itemClickListener: SgListItemViewHolder.ItemClickListener -) : ListAdapter(DIFF_CALLBACK) { +) : ListAdapter(UiListItem.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SgListItemViewHolder { return SgListItemViewHolder.create(itemClickListener, parent) @@ -41,19 +28,6 @@ class SgListItemAdapter( holder.bindTo(getItem(position), context) } - companion object { - val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: SgListItemWithDetails, - newItem: SgListItemWithDetails - ): Boolean = oldItem.id == newItem.id - - override fun areContentsTheSame( - oldItem: SgListItemWithDetails, - newItem: SgListItemWithDetails - ): Boolean = oldItem == newItem - } - } } @@ -63,12 +37,12 @@ class SgListItemViewHolder( ) : RecyclerView.ViewHolder(binding.root) { interface ItemClickListener { - fun onItemClick(anchor: View, item: SgListItemWithDetails) - fun onMoreOptionsClick(anchor: View, item: SgListItemWithDetails) - fun onSetWatchedClick(item: SgListItemWithDetails) + fun onItemClick(anchor: View, item: UiListItem) + fun onMoreOptionsClick(anchor: View, item: UiListItem) + fun onSetWatchedClick(item: UiListItem) } - var item: SgListItemWithDetails? = null + var item: UiListItem? = null init { // item @@ -103,137 +77,29 @@ class SgListItemViewHolder( } } - fun bindTo(item: SgListItemWithDetails?, context: Context) { + fun bindTo(item: UiListItem?, context: Context) { this.item = item - if (item == null) { - binding.textViewItemShowListTitle.text = null - return - } + // Title and favorite star + binding.textViewItemShowListTitle.text = item?.titleText + binding.imageViewItemShowListFavorited.isVisible = item?.isFavorite ?: false - binding.textViewItemShowListTitle.text = item.title - binding.imageViewItemShowListFavorited.isVisible = item.favorite + // Poster ImageTools.loadShowPosterResizeCrop( context, binding.imageViewItemShowListPoster, - item.posterSmall + item?.posterUrl ) - val isShow = item.type == ListItemTypes.TMDB_SHOW || item.type == ListItemTypes.TVDB_SHOW - // Hide set watched button for legacy season and episode items - binding.imageViewItemShowListSetWatched.isVisible = isShow - - // network, regular day and time, or type for legacy season/episode - if (isShow) { - // show details - val weekDay = item.releaseWeekDayOrDefault - val network = item.network - - val releaseTimeShow = TimeTools.getReleaseDateTime(context, item) - - binding.textViewItemShowListTimeAndNetwork.text = TextTools.dotSeparate( - network, - releaseTimeShow?.formatWithDeviceZoneToDayAndTime() - ) - - // next episode info - val fieldValue: String? = item.nextText - val hasNoNextEpisode = fieldValue.isNullOrEmpty() - binding.imageViewItemShowListSetWatched.isGone = hasNoNextEpisode - if (hasNoNextEpisode) { - // display show status if there is no next episode - binding.textViewItemShowListNextEpisodeTime.text = - ShowStatus.getStatus(context, item.statusOrUnknown) - binding.textViewItemShowListNextEpisode.text = null - } else { - binding.textViewItemShowListNextEpisode.text = fieldValue - - val releaseTimeEpisode = TimeTools.applyUserOffset(context, item.nextAirdateMs) - val displayExactDate = DisplaySettings.isDisplayExactDate(context) - val dateTime = if (displayExactDate) { - TimeTools.formatToLocalDateShort(context, releaseTimeEpisode) - } else { - TimeTools.formatToLocalRelativeTime(context, releaseTimeEpisode) - } - if (TimeTools.isSameWeekDay( - Instant.ofEpochMilli(releaseTimeEpisode.time), - releaseTimeShow?.toInstant(), - weekDay - )) { - // just display date - binding.textViewItemShowListNextEpisodeTime.text = dateTime - } else { - // display date and explicitly day - binding.textViewItemShowListNextEpisodeTime.text = context.getString( - R.string.format_date_and_day, dateTime, - TimeTools.formatToLocalDay(releaseTimeEpisode) - ) - } - } - - // remaining count - setRemainingCount(item.unwatchedCount) - } else if (item.type == ListItemTypes.SEASON) { - binding.textViewItemShowListTimeAndNetwork.setText(R.string.season) - binding.textViewItemShowListNextEpisodeTime.text = null - binding.textViewItemShowListRemaining.visibility = View.GONE - - // Note: Running query in adapter, but it's for legacy items, so fine for now. - val sesaonTvdbId: Int = item.itemRefId.toIntOrNull() ?: 0 - val seasonNumbersOrNull = SgRoomDatabase.getInstance(context).sgSeason2Helper() - .getSeasonNumbersByTvdbId(sesaonTvdbId) - if (seasonNumbersOrNull != null) { - binding.textViewItemShowListNextEpisode.text = SeasonTools.getSeasonString( - context, - seasonNumbersOrNull.number - ) - } else { - binding.textViewItemShowListNextEpisode.setText(R.string.unknown) - } - } else if (item.type == ListItemTypes.EPISODE) { - binding.textViewItemShowListTimeAndNetwork.setText(R.string.episode) - binding.textViewItemShowListRemaining.visibility = View.GONE - - // Note: Running query in adapter, but it's for legacy items, so fine for now. - val episodeTvdbId: Int = item.itemRefId.toIntOrNull() ?: 0 - val helper = SgRoomDatabase.getInstance(context).sgEpisode2Helper() - val episodeIdOrZero = helper.getEpisodeIdByTvdbId(episodeTvdbId) - val episodeInfo = if (episodeIdOrZero > 0) { - helper.getEpisodeInfo(episodeIdOrZero) - } else null - - if (episodeInfo != null) { - binding.textViewItemShowListNextEpisode.text = TextTools.getNextEpisodeString( - context, - episodeInfo.season, - episodeInfo.episodenumber, - episodeInfo.title - ) - val releaseTime = episodeInfo.firstReleasedMs - if (releaseTime != -1L) { - // "in 15 mins (Fri)" - val actualRelease = TimeTools.applyUserOffset(context, releaseTime) - binding.textViewItemShowListNextEpisodeTime.text = context.getString( - R.string.format_date_and_day, - TimeTools.formatToLocalRelativeTime(context, actualRelease), - TimeTools.formatToLocalDay(actualRelease) - ) - } - } else { - binding.textViewItemShowListNextEpisode.setText(R.string.unknown) - binding.textViewItemShowListNextEpisodeTime.text = null - } - } - } + // Set watched button + binding.imageViewItemShowListSetWatched.isVisible = item?.isSetWatchedButtonVisible ?: false - private fun setRemainingCount(unwatched: Int) { - val textView = binding.textViewItemShowListRemaining - textView.text = TextTools.getRemainingEpisodes(textView.resources, unwatched) - if (unwatched > 0) { - textView.visibility = View.VISIBLE - } else { - textView.visibility = View.GONE - } + binding.textViewItemShowListNextEpisode.text = item?.nextEpisodeText + binding.textViewItemShowListNextEpisodeTime.text = item?.nextEpisodeTimeText + binding.textViewItemShowListTimeAndNetwork.text = item?.timeAndNetworkText + // Use gone if not needed because remaining view adds padding + binding.textViewItemShowListRemaining.isVisible = item?.remainingText != null + binding.textViewItemShowListRemaining.text = item?.remainingText } companion object { diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt index 0a829edd74..d856e9bff9 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt @@ -1,5 +1,5 @@ -// Copyright 2023 Uwe Trottmann // SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright © 2021 Uwe Trottmann package com.battlelancer.seriesguide.lists @@ -9,9 +9,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.map import androidx.lifecycle.switchMap import androidx.sqlite.db.SimpleSQLiteQuery -import com.battlelancer.seriesguide.lists.database.SgListItemWithDetails import com.battlelancer.seriesguide.provider.SeriesGuideContract.Lists import com.battlelancer.seriesguide.provider.SeriesGuideContract.SgShow2Columns import com.battlelancer.seriesguide.provider.SeriesGuideDatabase.Tables @@ -26,10 +26,13 @@ class SgListItemViewModel( ) : AndroidViewModel(application) { private val queryString = MutableLiveData() - val sgListItemLiveData: LiveData> = + val sgListItemLiveData: LiveData> = queryString.switchMap { queryString -> SgRoomDatabase.getInstance(getApplication()).sgListHelper() .getListItemsWithDetails(SimpleSQLiteQuery(queryString, arrayOf(listId))) + }.map { items -> + val builder = UiListItemBuilder(getApplication()) + items.map { builder.buildFrom(it) } } init { diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/UiListItem.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/UiListItem.kt new file mode 100644 index 0000000000..94ad3da496 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/UiListItem.kt @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann + +package com.battlelancer.seriesguide.lists + +import android.content.Context +import androidx.recyclerview.widget.DiffUtil +import com.battlelancer.seriesguide.R +import com.battlelancer.seriesguide.lists.UiListItem.Companion.DIFF_CALLBACK +import com.battlelancer.seriesguide.lists.database.SgListItemWithDetails +import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItemTypes +import com.battlelancer.seriesguide.provider.SgRoomDatabase +import com.battlelancer.seriesguide.settings.DisplaySettings +import com.battlelancer.seriesguide.shows.overview.SeasonTools +import com.battlelancer.seriesguide.shows.tools.ShowStatus +import com.battlelancer.seriesguide.util.TextTools +import com.battlelancer.seriesguide.util.TimeTools +import com.battlelancer.seriesguide.util.TimeTools.formatWithDeviceZoneToDayAndTime +import org.threeten.bp.Instant + +/** + * UI model for a list item for [SgListItemAdapter]. + */ +data class UiListItem( + /** + * Database row ID. Used as the stable ID in [DIFF_CALLBACK]. + */ + val id: Long, + val listItemId: String, + val showId: Long, + @ListItemTypes val type: Int, + + val isSetWatchedButtonVisible: Boolean, + val isFavorite: Boolean, + + /** + * See [com.battlelancer.seriesguide.shows.database.SgShow2.posterSmall]. + */ + val posterUrl: String?, + + val titleText: String, + val nextEpisodeText: String?, + val nextEpisodeTimeText: String?, + val timeAndNetworkText: String, + val remainingText: String?, + + /** + * Database ID of the next episode; 0 if there is none. + */ + val nextEpisodeId: Long, +) { + /** + * Whether this item represents a show (TMDB or legacy TVDB). + */ + val isShow: Boolean + get() = type == ListItemTypes.TMDB_SHOW || type == ListItemTypes.TVDB_SHOW + + companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UiListItem, newItem: UiListItem): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: UiListItem, newItem: UiListItem): Boolean = + oldItem == newItem + } + } +} + +/** + * Converts [SgListItemWithDetails] rows (coming from the database) into + * [UiListItem] instances that are ready to be bound by [SgListItemAdapter]. + */ +class UiListItemBuilder(private val context: Context) { + + fun buildFrom(item: SgListItemWithDetails): UiListItem { + return when (item.type) { + ListItemTypes.TMDB_SHOW, ListItemTypes.TVDB_SHOW -> buildShow(item) + ListItemTypes.SEASON -> buildSeason(item) + ListItemTypes.EPISODE -> buildEpisode(item) + else -> throw IllegalArgumentException("Unsupported item type ${item.type}") + } + } + + private fun buildShow(item: SgListItemWithDetails): UiListItem { + val releaseDateTime = TimeTools.getReleaseDateTime(context, item) + val timeAndNetwork = TextTools.dotSeparate( + item.network, + releaseDateTime?.formatWithDeviceZoneToDayAndTime() + ) + + val nextEpisodeText = item.nextText + val nextEpisodeTimeText: String? + val hasNextEpisode = !nextEpisodeText.isNullOrEmpty() + if (!hasNextEpisode) { + // Display show status if there is no next episode + nextEpisodeTimeText = ShowStatus.getStatus(context, item.statusOrUnknown) + } else { + val releaseTimeEpisode = + TimeTools.applyUserOffset(context, item.nextAirdateMs) + val displayExactDate = DisplaySettings.isDisplayExactDate(context) + val dateTime = if (displayExactDate) { + TimeTools.formatToLocalDateShort(context, releaseTimeEpisode) + } else { + TimeTools.formatToLocalRelativeTime(context, releaseTimeEpisode) + } + nextEpisodeTimeText = if ( + TimeTools.isSameWeekDay( + Instant.ofEpochMilli(releaseTimeEpisode.time), + releaseDateTime?.toInstant(), + item.releaseWeekDayOrDefault + ) + ) { + dateTime + } else { + context.getString( + R.string.format_date_and_day, + dateTime, + TimeTools.formatToLocalDay(releaseTimeEpisode) + ) + } + } + + // remaining count + val remainingText = item.unwatchedCount + .let { + if (it > 0) { + TextTools.getRemainingEpisodes(context.resources, it) + } else null + } + + return UiListItem( + id = item.id, + listItemId = item.listItemId, + showId = item.showId, + type = item.type, + isSetWatchedButtonVisible = hasNextEpisode, + isFavorite = item.favorite, + posterUrl = item.posterSmall, + titleText = item.title, + nextEpisodeText = nextEpisodeText, + nextEpisodeTimeText = nextEpisodeTimeText, + timeAndNetworkText = timeAndNetwork, + remainingText = remainingText, + nextEpisodeId = item.nextEpisodeId, + ) + } + + private fun buildSeason(item: SgListItemWithDetails): UiListItem { + // Note: running a database query per item, but it's for legacy items, so fine for now + val seasonTvdbId = item.itemRefId.toIntOrNull() ?: 0 + val seasonNumbersOrNull = SgRoomDatabase.getInstance(context) + .sgSeason2Helper() + .getSeasonNumbersByTvdbId(seasonTvdbId) + val nextEpisodeText = if (seasonNumbersOrNull != null) { + SeasonTools.getSeasonString(context, seasonNumbersOrNull.number) + } else { + context.getString(R.string.unknown) + } + + return UiListItem( + id = item.id, + listItemId = item.listItemId, + showId = item.showId, + type = item.type, + isSetWatchedButtonVisible = false, + isFavorite = item.favorite, + posterUrl = item.posterSmall, + titleText = item.title, + nextEpisodeText = nextEpisodeText, + nextEpisodeTimeText = null, + timeAndNetworkText = context.getString(R.string.season), + remainingText = null, + nextEpisodeId = 0, + ) + } + + private fun buildEpisode(item: SgListItemWithDetails): UiListItem { + // Note: running a database query per item, but it's for legacy items, so fine for now + val episodeTvdbId = item.itemRefId.toIntOrNull() ?: 0 + val helper = SgRoomDatabase.getInstance(context).sgEpisode2Helper() + val episodeIdOrZero = helper.getEpisodeIdByTvdbId(episodeTvdbId) + val episodeInfo = if (episodeIdOrZero > 0) { + helper.getEpisodeInfo(episodeIdOrZero) + } else null + + val nextEpisodeText: String + val nextEpisodeTimeText: String? + + if (episodeInfo != null) { + nextEpisodeText = TextTools.getNextEpisodeString( + context, + episodeInfo.season, + episodeInfo.episodenumber, + episodeInfo.title + ) + val releaseTime = episodeInfo.firstReleasedMs + nextEpisodeTimeText = if (releaseTime != -1L) { + // "in 15 mins (Fri)" + val actualRelease = TimeTools.applyUserOffset(context, releaseTime) + context.getString( + R.string.format_date_and_day, + TimeTools.formatToLocalRelativeTime(context, actualRelease), + TimeTools.formatToLocalDay(actualRelease) + ) + } else null + } else { + nextEpisodeText = context.getString(R.string.unknown) + nextEpisodeTimeText = null + } + + return UiListItem( + id = item.id, + listItemId = item.listItemId, + showId = item.showId, + type = item.type, + isSetWatchedButtonVisible = false, + isFavorite = item.favorite, + posterUrl = item.posterSmall, + titleText = item.title, + nextEpisodeText = nextEpisodeText, + nextEpisodeTimeText = nextEpisodeTimeText, + timeAndNetworkText = context.getString(R.string.episode), + remainingText = null, + nextEpisodeId = 0, + ) + } +} + From 5efc9126f9e405abd63c330e5d69fb22f23d3544 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 7 May 2026 15:48:55 +0200 Subject: [PATCH 005/104] Lists: migrate list items LiveData to Flow, fetch and map in background --- .../seriesguide/lists/SgListFragment.kt | 21 +++++++++--- .../seriesguide/lists/SgListItemViewModel.kt | 32 ++++++++++++++----- .../lists/database/SgListHelper.kt | 3 +- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt index e327f91470..7747fc14ab 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt @@ -14,6 +14,9 @@ import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.databinding.FragmentListBinding @@ -26,6 +29,8 @@ import com.battlelancer.seriesguide.ui.OverviewActivity import com.battlelancer.seriesguide.ui.widgets.SgFastScroller import com.battlelancer.seriesguide.util.ViewTools import com.battlelancer.seriesguide.util.startActivityWithAnimation +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -79,11 +84,17 @@ class SgListFragment : Fragment() { } } - model.sgListItemLiveData.observe(viewLifecycleOwner) { - val bindingOnDemand = this.binding ?: return@observe - bindingOnDemand.recyclerViewListItems.isGone = it.isEmpty() - bindingOnDemand.emptyViewList.isGone = it.isNotEmpty() - adapter.submitList(it) + viewLifecycleOwner.lifecycleScope.launch { + // This is a page in a RecyclerView-based ViewPager2, so use STARTED to work with the + // pager prefetching next/previous pages. + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + model.items.collectLatest { + val bindingOnDemand = this@SgListFragment.binding ?: return@collectLatest + bindingOnDemand.recyclerViewListItems.isGone = it.isEmpty() + bindingOnDemand.emptyViewList.isGone = it.isNotEmpty() + adapter.submitList(it) + } + } } } diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt index d856e9bff9..eaa270393e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListItemViewModel.kt @@ -5,17 +5,22 @@ package com.battlelancer.seriesguide.lists import android.app.Application import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.map -import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope import androidx.sqlite.db.SimpleSQLiteQuery import com.battlelancer.seriesguide.provider.SeriesGuideContract.Lists import com.battlelancer.seriesguide.provider.SeriesGuideContract.SgShow2Columns import com.battlelancer.seriesguide.provider.SeriesGuideDatabase.Tables import com.battlelancer.seriesguide.provider.SgRoomDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Uses raw query to get list items with show details. @@ -25,15 +30,26 @@ class SgListItemViewModel( application: Application ) : AndroidViewModel(application) { - private val queryString = MutableLiveData() - val sgListItemLiveData: LiveData> = - queryString.switchMap { queryString -> + private val queryString = MutableStateFlow("") + val items: StateFlow> = queryString + .flatMapLatest { queryString -> SgRoomDatabase.getInstance(getApplication()).sgListHelper() .getListItemsWithDetails(SimpleSQLiteQuery(queryString, arrayOf(listId))) - }.map { items -> + } + .map { items -> val builder = UiListItemBuilder(getApplication()) items.map { builder.buildFrom(it) } } + // UiListItemBuilder may do database queries for legacy season and episode items, so use + // IO dispatcher for both. + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + // Note: the associated fragment is shown in a RecyclerView-based ViewPager2 and + // SgListFragment uses repeatOnLifecycle(Lifecycle.State.STARTED) + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) init { updateQuery() diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt index efdcb4ebb8..e38744e31f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt @@ -13,6 +13,7 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.room.Transaction import androidx.sqlite.db.SupportSQLiteQuery +import kotlinx.coroutines.flow.Flow import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItemTypes import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItems import com.battlelancer.seriesguide.provider.SeriesGuideContract.Lists @@ -45,7 +46,7 @@ interface SgListHelper { fun getListItemsWithTmdbId(tmdbId: Int): List @RawQuery(observedEntities = [SgListItem::class, SgShow2::class]) - fun getListItemsWithDetails(query: SupportSQLiteQuery): LiveData> + fun getListItemsWithDetails(query: SupportSQLiteQuery): Flow> @Query("SELECT * FROM listitems WHERE list_id = :listId") fun getListItemsForExport(listId: String): List From d34eeb5371933a5e62f8033281871ba22facbaa2 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 14 May 2026 14:12:18 +0200 Subject: [PATCH 006/104] DisplaySettings: inline saving ignore articles setting --- .../battlelancer/seriesguide/movies/MoviesOptionsMenu.kt | 4 +--- .../battlelancer/seriesguide/settings/DisplaySettings.kt | 8 +++++++- .../seriesguide/shows/ShowsDistillationSettings.kt | 6 ++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/MoviesOptionsMenu.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/MoviesOptionsMenu.kt index 33855e6579..7a8faa9a6f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/MoviesOptionsMenu.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/MoviesOptionsMenu.kt @@ -67,9 +67,7 @@ class MoviesOptionsMenu(val activity: Activity) : } private fun changeSortIgnoreArticles(value: Boolean, activity: Activity) { - PreferenceManager.getDefaultSharedPreferences(activity).edit() - .putBoolean(DisplaySettings.KEY_SORT_IGNORE_ARTICLE, value) - .apply() + DisplaySettings.saveSortOrderIgnoringArticles(activity, value) // refresh icon state activity.invalidateOptionsMenu() diff --git a/app/src/main/java/com/battlelancer/seriesguide/settings/DisplaySettings.kt b/app/src/main/java/com/battlelancer/seriesguide/settings/DisplaySettings.kt index fe77fe0211..395b74c9f1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/settings/DisplaySettings.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/settings/DisplaySettings.kt @@ -21,7 +21,7 @@ object DisplaySettings { const val KEY_SHOWS_TIME_OFFSET = "com.battlelancer.seriesguide.timeoffset" const val KEY_NO_RELEASED_EPISODES = "onlyFutureEpisodes" const val KEY_HIDE_SPECIALS = "onlySeasonEpisodes" - const val KEY_SORT_IGNORE_ARTICLE = "com.battlelancer.seriesguide.sort.ignorearticle" + private const val KEY_SORT_IGNORE_ARTICLE = "com.battlelancer.seriesguide.sort.ignorearticle" const val KEY_DISPLAY_EXACT_DATE = "com.battlelancer.seriesguide.shows.exactdate" const val KEY_PREVENT_SPOILERS = "com.battlelancer.seriesguide.PREVENT_SPOILERS" @@ -84,6 +84,12 @@ object DisplaySettings { .getBoolean(KEY_HIDE_SPECIALS, false) } + fun saveSortOrderIgnoringArticles(context: Context, value: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(KEY_SORT_IGNORE_ARTICLE, value) + } + } + /** * Whether shows and movies sorted by title should ignore the leading article. */ diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsDistillationSettings.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsDistillationSettings.kt index e588961c10..a5affe9117 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsDistillationSettings.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsDistillationSettings.kt @@ -48,11 +48,8 @@ class ShowsDistillationSettings( KEY_SORT_FAVORITES_FIRST, showSortOrder.isSortFavoritesFirst ) - putBoolean( - DisplaySettings.KEY_SORT_IGNORE_ARTICLE, - showSortOrder.isSortIgnoreArticles - ) } + DisplaySettings.saveSortOrderIgnoringArticles(context, showSortOrder.isSortIgnoreArticles) // broadcast new sort order sortOrder.value = showSortOrder @@ -106,6 +103,7 @@ class ShowsDistillationSettings( companion object { const val TITLE_ID = 0 + // @deprecated Only supporting alphabetical sort order going forward. // int TITLE_REVERSE_ID = 1; const val OLDEST_EPISODE_ID = 2 From 035609803a867be98326833e55f0c9a798974413 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 14 May 2026 10:36:17 +0200 Subject: [PATCH 007/104] SgMovie: introduce constant for unknown release date --- .../seriesguide/movies/MovieViewHolder.kt | 2 +- .../seriesguide/movies/database/SgMovie.kt | 13 ++++++++++--- .../seriesguide/movies/details/MovieDetails.java | 3 ++- .../seriesguide/movies/tools/MovieTools.kt | 3 ++- .../seriesguide/provider/SeriesGuideContract.java | 3 +-- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/MovieViewHolder.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/MovieViewHolder.kt index 1dddfc4c34..8426c33ca1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/MovieViewHolder.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/MovieViewHolder.kt @@ -120,7 +120,7 @@ class UiMovieBuilder(context: Context) { return UiMovie( sgMovie.tmdbId, sgMovie.title.orEmpty(), - if (sgMovie.releasedMsOrDefault != Long.MAX_VALUE) { + if (sgMovie.releasedMsOrDefault != SgMovie.RELEASED_MS_UNKNOWN) { dateFormatMovieReleaseDate.format(sgMovie.releasedMsOrDefault) } else { "" diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/database/SgMovie.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/database/SgMovie.kt index 46c7c53826..394b54e296 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/database/SgMovie.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/database/SgMovie.kt @@ -43,6 +43,9 @@ data class SgMovie( @ColumnInfo(name = Movies.OVERVIEW) val overview: String? = null, + /** + * Get safely via [releasedMsOrDefault]. + */ @ColumnInfo(name = Movies.RELEASED_UTC_MS) val releasedMs: Long? = null, @@ -87,11 +90,11 @@ data class SgMovie( ) { /** - * If [releasedMs] is not null returns it, otherwise [Long.MAX_VALUE]. - * See [Movies.RELEASED_UTC_MS]. + * Release date in milliseconds. [RELEASED_MS_UNKNOWN] if unknown, assuming the true date is + * likely in the future (also helps to correctly sort movies by release date). */ val releasedMsOrDefault: Long - get() = releasedMs ?: Long.MAX_VALUE + get() = releasedMs ?: RELEASED_MS_UNKNOWN val runtimeMinOrDefault: Int get() = runtimeMin ?: 0 @@ -110,4 +113,8 @@ data class SgMovie( val lastUpdatedOrDefault: Long get() = lastUpdated ?: 0 + + companion object { + const val RELEASED_MS_UNKNOWN = Long.MAX_VALUE + } } \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java index 28f9b6934d..3ac628e5e8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java @@ -5,6 +5,7 @@ import android.content.ContentValues; import androidx.annotation.Nullable; +import com.battlelancer.seriesguide.movies.database.SgMovie; import com.battlelancer.seriesguide.provider.SeriesGuideContract.Movies; import com.battlelancer.seriesguide.util.DBUtils; import com.battlelancer.seriesguide.util.TextTools; @@ -129,7 +130,7 @@ public ContentValues toContentValuesUpdate() { // also helps correctly sorting movies by release date Date releaseDate = tmdbMovie.release_date; values.put(Movies.RELEASED_UTC_MS, - releaseDate == null ? Long.MAX_VALUE : releaseDate.getTime()); + releaseDate == null ? SgMovie.RELEASED_MS_UNKNOWN : releaseDate.getTime()); } return values; diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieTools.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieTools.kt index 1abd1849c1..6d003daf00 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/tools/MovieTools.kt @@ -11,6 +11,7 @@ import com.battlelancer.seriesguide.jobs.movies.MovieWatchedJob import com.battlelancer.seriesguide.jobs.movies.MovieWatchlistJob import com.battlelancer.seriesguide.modules.ApplicationContext import com.battlelancer.seriesguide.movies.MoviesSettings +import com.battlelancer.seriesguide.movies.database.SgMovie import com.battlelancer.seriesguide.movies.details.MovieDetails import com.battlelancer.seriesguide.movies.tools.MovieTools.Lists import com.battlelancer.seriesguide.provider.SeriesGuideContract.Movies @@ -375,7 +376,7 @@ class MovieTools @Inject constructor( * [Movies.RELEASED_UTC_MS]. */ fun movieReleaseDateFrom(releaseDateMs: Long): Date? { - return if (releaseDateMs == Long.MAX_VALUE) null else Date(releaseDateMs) + return if (releaseDateMs == SgMovie.RELEASED_MS_UNKNOWN) null else Date(releaseDateMs) } /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java b/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java index 98ecc68d84..16084e4410 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java +++ b/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java @@ -976,8 +976,7 @@ interface MoviesColumns { String OVERVIEW = "movies_overview"; /** - * Release date in milliseconds. Store Long.MAX if unknown, as it is likely in the future - * (also helps correctly sorting movies by release date). + * See {@link SgMovie#getReleasedMsOrDefault()}. */ String RELEASED_UTC_MS = "movies_released"; From 6527f40bbdb0f0801c70613d514f7db5b26a9605 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 May 2026 09:39:22 +0200 Subject: [PATCH 008/104] Follow-up: DisplaySettings: inline saving ignore articles setting --- .../seriesguide/lists/ListsActivityImpl.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/ListsActivityImpl.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/ListsActivityImpl.kt index 72f589d8e4..ded3b878c8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/ListsActivityImpl.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/ListsActivityImpl.kt @@ -215,12 +215,10 @@ open class ListsActivityImpl : BaseTopActivity() { } private fun toggleSortIgnoreArticles() { - PreferenceManager.getDefaultSharedPreferences(this).edit() - .putBoolean( - DisplaySettings.KEY_SORT_IGNORE_ARTICLE, - !DisplaySettings.isSortOrderIgnoringArticles(this) - ) - .apply() + DisplaySettings.saveSortOrderIgnoringArticles( + this, + !DisplaySettings.isSortOrderIgnoringArticles(this) + ) // refresh icon state invalidateOptionsMenu() From 95458d0d71493e6bf8f1f6aa6757b2671d670b06 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 May 2026 09:44:33 +0200 Subject: [PATCH 009/104] TmdbTools2: log details on getShowDetails error --- .../java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt index 09a1e2ae44..10914a5bd8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/tmdbapi/TmdbTools2.kt @@ -38,6 +38,7 @@ import com.battlelancer.seriesguide.people.Person as SgPerson class TmdbTools2 { fun getShowDetails(showTmdbId: Int, language: String, context: Context): TvShow? { + val action = "show details showTmdbId = $showTmdbId language = $language" val tmdb = SgApp.getServicesComponent(context.applicationContext).tmdb() try { val response = tmdb.tvService() @@ -47,10 +48,10 @@ class TmdbTools2 { val results = response.body() if (results != null) return results } else { - Errors.logAndReport("show details", response) + Errors.logAndReport(action, response) } } catch (e: Exception) { - Errors.logAndReport("show details", e) + Errors.logAndReport(action, e) } return null } From 19dbcf1c1265651cafe350ca95e32dddc9593e32 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 May 2026 13:13:41 +0200 Subject: [PATCH 010/104] CI: run for pull requests into release branches, always lint amzn --- .github/workflows/build-test-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index 5a9d430b70..bbafa94cdf 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -5,6 +5,7 @@ on: branches: - 'dev' - 'main' + - 'releases/**' workflow_dispatch: # Allow running manually from web UI jobs: @@ -62,7 +63,6 @@ jobs: run: ./gradlew :app:lintPureRelease - name: Lint amzn release - if: ${{ contains(github.ref, 'release') }} run: ./gradlew :app:lintAmazonRelease - name: Upload lint reports From 4bd332cfa2abda492efb35cde1848787e92b2eef Mon Sep 17 00:00:00 2001 From: Uwe Date: Sun, 3 May 2026 06:45:50 +0200 Subject: [PATCH 011/104] Movies in lists: support movies in manage lists dialog --- .../lists/ManageListsDialogFragment.kt | 111 +++++++-------- .../ManageListsDialogFragmentViewModel.kt | 129 +++++++++++++----- .../seriesguide/lists/SgListFragment.kt | 2 +- .../lists/database/SgListHelper.kt | 4 +- .../provider/SeriesGuideContract.java | 7 +- .../shows/ShowMenuItemClickListener.kt | 2 +- .../shows/overview/OverviewActivityImpl.kt | 2 +- 7 files changed, 161 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragment.kt index 0f66962523..4ca8f92a4e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragment.kt @@ -1,5 +1,5 @@ -// Copyright 2023 Uwe Trottmann // SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright © 2023 Uwe Trottmann package com.battlelancer.seriesguide.lists @@ -14,39 +14,41 @@ import androidx.appcompat.app.AppCompatDialogFragment import androidx.core.view.isGone import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.databinding.DialogManageListsBinding import com.battlelancer.seriesguide.databinding.ItemListCheckedBinding +import com.battlelancer.seriesguide.lists.ManageListsDialogFragmentViewModel.ListItem import com.battlelancer.seriesguide.lists.ManageListsDialogFragmentViewModel.ListWithItem -import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItemTypes import com.battlelancer.seriesguide.util.ViewTools import com.battlelancer.seriesguide.util.safeShow import com.google.android.material.dialog.MaterialAlertDialogBuilder /** * Displays a dialog displaying all user created lists, - * allowing to add or remove the given show for any. + * allowing to add or remove the given show or movie for any. */ class ManageListsDialogFragment : AppCompatDialogFragment() { private var binding: DialogManageListsBinding? = null - private val model by viewModels { - ManageListsDialogFragmentViewModelFactory(requireActivity().application, showId) - } - private var showId: Long = 0 - /** - * Remains 0 if TMDB id for show not found (show is not migrated to TMDB data). - */ - private var showTmdbId = 0 + private val listItem: ListItem by lazy { + val args = requireArguments() + val movieTmdbId = args.getInt(ARG_INT_MOVIE_TMDB_ID, 0) + if (movieTmdbId != 0) { + ListItem.Movie(movieTmdbId) + } else { + ListItem.Show(args.getLong(ARG_LONG_SHOW_ID)) + } + } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - showId = requireArguments().getLong(ARG_LONG_SHOW_ID) + private val model by viewModels { + ManageListsDialogFragmentViewModelFactory(requireActivity().application, listItem) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -63,18 +65,17 @@ class ManageListsDialogFragment : AppCompatDialogFragment() { } // Note: viewLifeCycleOwner not available for DialogFragment, use DialogFragment itself. - model.showDetails.observe(this) { showDetails -> - if (showDetails == null) { + model.listItemDetails.observe(this) { details -> + if (details == null) { Toast.makeText(requireContext(), R.string.database_error, Toast.LENGTH_LONG).show() dismiss() return@observe } - showTmdbId = showDetails.tmdbId ?: 0 - binding.item.text = showDetails.title + binding.item.text = details.title - if (showTmdbId <= 0) { - // Note: see OK button handler that prevents changing lists if not migrated. + // For shows: warn if not yet migrated to TMDB data. Not applicable for movies. + if (listItem is ListItem.Show && details.tmdbId <= 0) { ViewTools.configureNotMigratedWarning(binding.textViewManageListsError, true) } } @@ -86,32 +87,10 @@ class ManageListsDialogFragment : AppCompatDialogFragment() { .setView(binding.root) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - // Note: see show details loader that prevents loading list data if not migrated. - if ((model.showDetails.value?.tmdbId ?: 0) <= 0) { + lifecycleScope.launch { + model.saveChanges() dismiss() - return@setPositiveButton } - // add item to selected lists, remove from previously selected lists - val addToTheseLists: MutableList = ArrayList() - val removeFromTheseLists: MutableList = ArrayList() - val lists = model.listsWithItem.value - ?: return@setPositiveButton // Do nothing, lists not loaded, yet. - lists - .filter { it.isItemOnList != it.isItemOnListOriginal } - .forEach { - if (it.isItemOnListOriginal) { - // remove from list - removeFromTheseLists.add(it.listId) - } else { - // add to list - addToTheseLists.add(it.listId) - } - } - ListsTools.changeListsOfItem( - requireContext(), showTmdbId, - ListItemTypes.TMDB_SHOW, addToTheseLists, removeFromTheseLists - ) - dismiss() } .create() } @@ -185,28 +164,44 @@ class ManageListsDialogFragment : AppCompatDialogFragment() { companion object { private const val TAG = "listsdialog" private const val ARG_LONG_SHOW_ID = "show_id" + private const val ARG_INT_MOVIE_TMDB_ID = "movie_tmdb_id" + + private fun newInstance(listItem: ListItem): ManageListsDialogFragment = + ManageListsDialogFragment().apply { + arguments = Bundle().apply { + when (listItem) { + is ListItem.Show -> putLong(ARG_LONG_SHOW_ID, listItem.showId) + is ListItem.Movie -> putInt(ARG_INT_MOVIE_TMDB_ID, listItem.movieTmdbId) + } + } + } - private fun newInstance(showId: Long): ManageListsDialogFragment { - val f = ManageListsDialogFragment() - val args = Bundle() - args.putLong(ARG_LONG_SHOW_ID, showId) - f.arguments = args - return f + /** + * Replaces any currently shown list dialog and shows this in its place. + * (Does not add it to the back stack.) + */ + private fun ManageListsDialogFragment.replaceAndShow(fm: FragmentManager): Boolean { + val ft = fm.beginTransaction() + fm.findFragmentByTag(TAG)?.also { ft.remove(it) } + return safeShow(fm, ft, TAG) } /** * Display a dialog which asks if the user wants to add the given show to one or more lists. */ @JvmStatic - fun show(fm: FragmentManager, showId: Long): Boolean { + fun showForShow(fm: FragmentManager, showId: Long): Boolean { if (showId <= 0) return false - // replace any currently showing list dialog (do not add it to the back stack) - val ft = fm.beginTransaction() - val prev = fm.findFragmentByTag(TAG) - if (prev != null) { - ft.remove(prev) - } - return newInstance(showId).safeShow(fm, ft, TAG) + return newInstance(ListItem.Show(showId)).replaceAndShow(fm) + } + + /** + * Display a dialog which asks if the user wants to add the given movie to one or more lists. + */ + @JvmStatic + fun showForMovie(fm: FragmentManager, movieTmdbId: Int): Boolean { + if (movieTmdbId <= 0) return false + return newInstance(ListItem.Movie(movieTmdbId)).replaceAndShow(fm) } } } \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragmentViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragmentViewModel.kt index efc545b36b..1f211be6ed 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragmentViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/ManageListsDialogFragmentViewModel.kt @@ -1,5 +1,5 @@ -// Copyright 2023 Uwe Trottmann // SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright © 2023 Uwe Trottmann package com.battlelancer.seriesguide.lists @@ -10,27 +10,60 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope +import com.battlelancer.seriesguide.provider.SeriesGuideContract.ListItemTypes import com.battlelancer.seriesguide.provider.SgRoomDatabase -import com.battlelancer.seriesguide.shows.database.SgShow2Minimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** - * Loads show details and lists with the initial is on list state for the item, + * Loads show or movie details and lists with the initial is-on-list state for the item, * allows to modify the state without changing the database. */ class ManageListsDialogFragmentViewModel( application: Application, - showId: Long + private val listItem: ListItem ) : AndroidViewModel(application) { - val showDetails = liveData(context = viewModelScope.coroutineContext + Dispatchers.IO) { - val showDetails = SgRoomDatabase.getInstance(application) - .sgShow2Helper() - .getShowMinimal(showId) - emit(showDetails) - if (showDetails != null) { - loadLists(showDetails) + @ListItemTypes + private val listItemType: Int = when (listItem) { + is ListItem.Show -> ListItemTypes.TMDB_SHOW + is ListItem.Movie -> ListItemTypes.TMDB_MOVIE + } + + sealed class ListItem { + /** A show identified by its local database row ID. */ + data class Show(val showId: Long) : ListItem() + + /** A movie identified by its TMDB ID. */ + data class Movie(val movieTmdbId: Int) : ListItem() + } + + /** + * Minimal details needed to display the item in the dialog. + * [tmdbId] is 0 for shows that have not yet been migrated to TMDB data. + */ + data class ItemDetails( + val title: String, + val tmdbId: Int + ) + + val listItemDetails = liveData(context = viewModelScope.coroutineContext + Dispatchers.IO) { + val db = SgRoomDatabase.getInstance(application) + val details: ItemDetails? = when (listItem) { + is ListItem.Show -> { + db.sgShow2Helper().getShowMinimal(listItem.showId) + ?.let { ItemDetails(it.title, it.tmdbId ?: 0) } + } + + is ListItem.Movie -> { + db.movieHelper().getMovieTitle(listItem.movieTmdbId) + ?.let { ItemDetails(it, listItem.movieTmdbId) } + } + } + emit(details) + if (details != null && details.tmdbId > 0) { + loadLists(details.tmdbId) } } @@ -43,25 +76,22 @@ class ManageListsDialogFragmentViewModel( val listsWithItem = MutableLiveData>() - private fun loadLists(showDetails: SgShow2Minimal) { + private fun loadLists(tmdbId: Int) { viewModelScope.launch(Dispatchers.IO) { - val showTmdbId = showDetails.tmdbId ?: 0 - if (showTmdbId > 0) { - val listHelper = SgRoomDatabase.getInstance(getApplication()).sgListHelper() - val listItemsForShow = listHelper.getListItemsWithTmdbId(showTmdbId) - val initialListsWithItem = listHelper - .getListsForExport() - .map { list -> - val isItemOnList = listItemsForShow.find { list.listId == it.listId } != null - ListWithItem( - listId = list.listId, - listName = list.name, - isItemOnListOriginal = isItemOnList, - isItemOnList = isItemOnList - ) - } - listsWithItem.postValue(initialListsWithItem) - } + val listHelper = SgRoomDatabase.getInstance(getApplication()).sgListHelper() + val listItemsForItem = listHelper.getListItemsWithTmdbId(tmdbId, listItemType) + val initialListsWithItem = listHelper + .getListsForExport() + .map { list -> + val isItemOnList = listItemsForItem.find { list.listId == it.listId } != null + ListWithItem( + listId = list.listId, + listName = list.name, + isItemOnListOriginal = isItemOnList, + isItemOnList = isItemOnList + ) + } + listsWithItem.postValue(initialListsWithItem) } } @@ -75,16 +105,53 @@ class ManageListsDialogFragmentViewModel( } } + /** + * Adds the item to selected lists, removes it from previously selected lists. + * + * Does nothing if missing required data. + */ + suspend fun saveChanges() = withContext(Dispatchers.IO) { + val tmdbId = listItemDetails.value?.tmdbId ?: 0 + if (tmdbId <= 0) { + return@withContext // Show has no TMDB ID or failed to load it from database + } + + val lists = listsWithItem.value + ?: return@withContext // Do nothing, lists not loaded, yet + + + val addToTheseLists = mutableListOf() + val removeFromTheseLists = mutableListOf() + lists + .filter { it.isItemOnList != it.isItemOnListOriginal } + .forEach { + if (it.isItemOnListOriginal) { + // Remove from list + removeFromTheseLists.add(it.listId) + } else { + // Add to list + addToTheseLists.add(it.listId) + } + } + ListsTools.changeListsOfItem( + getApplication(), + tmdbId, + listItemType, + addToTheseLists, + removeFromTheseLists + ) + } + } class ManageListsDialogFragmentViewModelFactory( private val application: Application, - private val showId: Long + private val listItem: ManageListsDialogFragmentViewModel.ListItem ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return ManageListsDialogFragmentViewModel(application, showId) as T + return ManageListsDialogFragmentViewModel(application, listItem) as T } } diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt index 7747fc14ab..8f4745649b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/SgListFragment.kt @@ -184,7 +184,7 @@ class SgListFragment : Fragment() { } R.id.menu_action_lists_manage -> { - ManageListsDialogFragment.show(fragmentManager, showId) + ManageListsDialogFragment.showForShow(fragmentManager, showId) return true } diff --git a/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt b/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt index e38744e31f..59b534c711 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/lists/database/SgListHelper.kt @@ -42,8 +42,8 @@ interface SgListHelper { @Query("SELECT COUNT(_id) FROM lists") fun getListsCount(): Int - @Query("SELECT * FROM listitems WHERE item_ref_id = :tmdbId AND item_type = ${ListItemTypes.TMDB_SHOW}") - fun getListItemsWithTmdbId(tmdbId: Int): List + @Query("SELECT * FROM listitems WHERE item_ref_id = :tmdbId AND item_type = :type") + fun getListItemsWithTmdbId(tmdbId: Int, @ListItemTypes type: Int): List @RawQuery(observedEntities = [SgListItem::class, SgShow2::class]) fun getListItemsWithDetails(query: SupportSQLiteQuery): Flow> diff --git a/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java b/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java index 16084e4410..3e83252df7 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java +++ b/app/src/main/java/com/battlelancer/seriesguide/provider/SeriesGuideContract.java @@ -941,7 +941,8 @@ interface ListItemsColumns { ListItemTypes.TVDB_SHOW, ListItemTypes.SEASON, ListItemTypes.EPISODE, - ListItemTypes.TMDB_SHOW + ListItemTypes.TMDB_SHOW, + ListItemTypes.TMDB_MOVIE }) public @interface ListItemTypes { int TVDB_SHOW = 1; @@ -954,6 +955,7 @@ interface ListItemsColumns { */ int EPISODE = 3; int TMDB_SHOW = 4; + int TMDB_MOVIE = 5; } interface MoviesColumns { @@ -1201,7 +1203,8 @@ public static boolean isValidItemType(int type) { return type == ListItemTypes.TVDB_SHOW || type == ListItemTypes.SEASON || type == ListItemTypes.EPISODE - || type == ListItemTypes.TMDB_SHOW; + || type == ListItemTypes.TMDB_SHOW + || type == ListItemTypes.TMDB_MOVIE; } } diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/ShowMenuItemClickListener.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/ShowMenuItemClickListener.kt index e87c9792f6..5f9d2aa77b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/ShowMenuItemClickListener.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/ShowMenuItemClickListener.kt @@ -48,7 +48,7 @@ class ShowMenuItemClickListener( return true } R.id.menu_action_shows_manage_lists -> { - ManageListsDialogFragment.show(fragmentManager, showId) + ManageListsDialogFragment.showForShow(fragmentManager, showId) return true } R.id.menu_action_shows_update -> { diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewActivityImpl.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewActivityImpl.kt index a49b4ac862..029cba3e0f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewActivityImpl.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewActivityImpl.kt @@ -264,7 +264,7 @@ open class OverviewActivityImpl : BaseMessageActivity() { return true } if (itemId == R.id.menu_show_manage_lists) { - ManageListsDialogFragment.show(supportFragmentManager, showId) + ManageListsDialogFragment.showForShow(supportFragmentManager, showId) return true } if (itemId == R.id.menu_overview_remove_show) { From 52aab0351d567429ab5fe33214966a29ce4fe3de Mon Sep 17 00:00:00 2001 From: Uwe Date: Sun, 3 May 2026 07:52:47 +0200 Subject: [PATCH 012/104] Movies in lists: display manage lists button if movie is in database --- .../battlelancer/seriesguide/movies/MovieLoader.kt | 1 + .../seriesguide/movies/details/MovieDetails.java | 13 +++++++++++++ .../movies/details/MovieDetailsFragment.kt | 9 +++++++++ app/src/main/res/layout/buttons_movie.xml | 12 +++++++++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/MovieLoader.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/MovieLoader.kt index d76e1b2dd2..c4a26b6975 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/MovieLoader.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/MovieLoader.kt @@ -49,6 +49,7 @@ internal class MovieLoader( // set local state for watched, collected and watchlist status // assumption: local db has the truth for these + details.isInDatabase = true details.isInCollection = dbMovieOrNull.inCollectionOrDefault details.isInWatchlist = dbMovieOrNull.inWatchlistOrDefault details.isWatched = dbMovieOrNull.watchedOrDefault diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java index 3ac628e5e8..87d47876e8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetails.java @@ -21,6 +21,7 @@ public class MovieDetails { private Ratings traktRatings; private Movie tmdbMovie; + private boolean isInDatabase; private boolean inCollection; private boolean inWatchlist; private boolean isWatched; @@ -48,6 +49,18 @@ public void tmdbMovie(Movie movie) { tmdbMovie = movie; } + /** + * If this movie is stored in the local database. + */ + public boolean isInDatabase() { + return isInDatabase; + } + + public MovieDetails setInDatabase(boolean inDatabase) { + isInDatabase = inDatabase; + return this; + } + public boolean isInCollection() { return inCollection; } diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt index 5d0e3386b9..818624ea8f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt @@ -36,6 +36,7 @@ import com.battlelancer.seriesguide.extensions.ActionsHelper import com.battlelancer.seriesguide.extensions.ExtensionManager import com.battlelancer.seriesguide.extensions.MovieActionsContract import com.battlelancer.seriesguide.getSgAppContainer +import com.battlelancer.seriesguide.lists.ManageListsDialogFragment import com.battlelancer.seriesguide.movies.MovieLoader import com.battlelancer.seriesguide.movies.MovieLocalizationDialogFragment import com.battlelancer.seriesguide.movies.MoviesSettings @@ -125,6 +126,10 @@ class MovieDetailsFragment : Fragment(), MovieActionsContract { // some action buttons binding.containerMovieButtons.apply { root.isGone = true + // Manage lists button + buttonMovieManageLists.setOnClickListener { + ManageListsDialogFragment.showForMovie(parentFragmentManager, tmdbId) + } // trailer button buttonMovieTrailer.apply { setOnClickListener { @@ -339,6 +344,10 @@ class MovieDetailsFragment : Fragment(), MovieActionsContract { val hideCheckIn = !isConnectedToTrakt || HexagonSettings.isEnabled(requireContext()) binding.containerMovieButtons.buttonMovieCheckIn.isGone = hideCheckIn + // Hide manage lists button if movie isn't in the database (and therefore can't show up in a + // list). + binding.containerMovieButtons.buttonMovieManageLists.isGone = !movieDetails.isInDatabase + // watched button binding.containerMovieButtons.buttonMovieWatched.also { it.text = TextTools.getWatchedButtonText(requireContext(), isWatched, plays) diff --git a/app/src/main/res/layout/buttons_movie.xml b/app/src/main/res/layout/buttons_movie.xml index 4fb1dae0e1..8958ebe29d 100644 --- a/app/src/main/res/layout/buttons_movie.xml +++ b/app/src/main/res/layout/buttons_movie.xml @@ -57,7 +57,7 @@ +