/** * SPDX-FileCopyrightText: 2020 Tobias Fella * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ import QtQuick import QtQuick.Controls as Controls import Qt.labs.platform import QtQuick.Layouts import QtQml.Models import org.kde.kirigami as Kirigami import org.kde.kasts Kirigami.ScrollablePage { id: subscriptionPage title: i18nc("@title of page with list of podcast subscriptions", "Subscriptions") LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft LayoutMirroring.childrenInherit: true anchors.margins: 0 padding: 0 property var lastFeed: "" supportsRefreshing: true onRefreshingChanged: { if(refreshing) { updateAllFeeds.run(); refreshing = false; } } property list pageActions: [ Kirigami.Action { visible: kastsMainWindow.isMobile text: i18nc("@title of page allowing to search for new podcasts online", "Discover") icon.name: "search" onTriggered: { applicationWindow().pageStack.push("qrc:/qt/qml/org/kde/kasts/qml/DiscoverPage.qml"); } }, Kirigami.Action { text: i18nc("@action:intoolbar", "Refresh All Podcasts") icon.name: "view-refresh" onTriggered: refreshing = true }, Kirigami.Action { id: addAction text: i18nc("@action:intoolbar", "Add Podcast…") icon.name: "list-add" onTriggered: { addSheet.open() } }, Kirigami.Action { id: sortActionRoot icon.name: "view-sort" text: i18nc("@action:intoolbar Open menu with options to sort subscriptions", "Sort") tooltip: i18nc("@info:tooltip", "Select how to sort subscriptions") property Controls.ActionGroup sortGroup: Controls.ActionGroup { } property Instantiator repeater: Instantiator { model: ListModel { id: sortModel // have to use script because i18n doesn't work within ListElement Component.onCompleted: { if (sortActionRoot.visible) { var sortList = [FeedsProxyModel.UnreadDescending, FeedsProxyModel.UnreadAscending, FeedsProxyModel.NewDescending, FeedsProxyModel.NewAscending, FeedsProxyModel.FavoriteDescending, FeedsProxyModel.FavoriteAscending, FeedsProxyModel.TitleAscending, FeedsProxyModel.TitleDescending] for (var i in sortList) { sortModel.append({"name": feedsModel.getSortName(sortList[i]), "iconName": feedsModel.getSortIconName(sortList[i]), "sortType": sortList[i]}); } } } } Kirigami.Action { visible: sortActionRoot.visible icon.name: model.iconName text: model.name checkable: true checked: kastsMainWindow.feedSorting === model.sortType Controls.ActionGroup.group: sortActionRoot.sortGroup onTriggered: { kastsMainWindow.feedSorting = model.sortType; } } onObjectAdded: (index, object) => { sortActionRoot.children.push(object); } } }, Kirigami.Action { id: searchActionButton icon.name: "search" text: i18nc("@action:intoolbar", "Search") checkable: true }, Kirigami.Action { id: importAction text: i18nc("@action:intoolbar", "Import Podcasts…") icon.name: "document-import" displayHint: Kirigami.DisplayHint.AlwaysHide onTriggered: importDialog.open() }, Kirigami.Action { text: i18nc("@action:intoolbar", "Export Podcasts…") icon.name: "document-export" displayHint: Kirigami.DisplayHint.AlwaysHide onTriggered: exportDialog.open() } ] // add the default actions through onCompleted to add them to the ones // defined above Component.onCompleted: { for (var i in feedList.contextualActionList) { pageActions.push(feedList.contextualActionList[i]); } } actions: pageActions header: Loader { anchors.right: parent.right anchors.left: parent.left active: searchActionButton.checked visible: active sourceComponent: SearchBar { proxyModel: feedsModel parentKey: searchActionButton showSearchFilters: false } } AddFeedSheet { id: addSheet } FileDialog { id: importDialog title: i18nc("@title:window", "Import Podcasts") folder: StandardPaths.writableLocation(StandardPaths.HomeLocation) nameFilters: [i18nc("@label:listbox File filter option in file dialog", "OPML Files (*.opml)"), i18nc("@label:listbox File filter option in file dialog", "XML Files (*.xml)"), i18nc("@label:listbox File filter option in file dialog", "All Files (*)")] onAccepted: DataManager.importFeeds(file) } FileDialog { id: exportDialog title: i18nc("@title:window", "Export Podcasts") folder: StandardPaths.writableLocation(StandardPaths.HomeLocation) nameFilters: [i18nc("@label:listbox File filter option in file dialog", "OPML Files (*.opml)"), i18nc("@label:listbox File filter option in file dialog", "All Files (*)")] onAccepted: DataManager.exportFeeds(file) fileMode: FileDialog.SaveFile } GridView { id: feedList currentIndex: -1 clip: true Kirigami.PlaceholderMessage { id: placeholderMessage visible: feedList.count === 0 width: Kirigami.Units.gridUnit * 20 anchors.centerIn: parent type: feedsModel.searchFilter === "" ? Kirigami.PlaceholderMessage.Actionable : Kirigami.PlaceholderMessage.Informational text: feedsModel.searchFilter === "" ? i18nc("@info Placeholder message for empty podcast list", "No podcasts added yet") : i18nc("@info Placeholder message for podcast list when no podcast matches the search criteria", "No podcasts found") explanation: feedsModel.searchFilter === "" ? i18nc("@info:tipoftheday", "Get started by adding podcasts:") : null readonly property int buttonSize: Math.max(discoverButton.implicitWidth, addButton.implicitWidth, importButton.implicitWidth, syncButton.implicitWidth) // These actions are also in the toolbar, but duplicating some here // to give them more descriptive names Controls.Button { id: discoverButton visible: feedsModel.searchFilter === "" Layout.preferredWidth: placeholderMessage.buttonSize Layout.alignment: Qt.AlignHCenter Layout.topMargin: Kirigami.Units.gridUnit action: Kirigami.Action { icon.name: "search" text: i18nc("@action:button", "Search Online") onTriggered: pushPage("DiscoverPage") } } Controls.Button { id: addButton visible: feedsModel.searchFilter === "" Layout.preferredWidth: placeholderMessage.buttonSize Layout.alignment: Qt.AlignHCenter action: addAction } Controls.Button { id: importButton visible: feedsModel.searchFilter === "" Layout.preferredWidth: placeholderMessage.buttonSize Layout.alignment: Qt.AlignHCenter action: importAction } Controls.Button { id: syncButton visible: feedsModel.searchFilter === "" Layout.preferredWidth: placeholderMessage.buttonSize Layout.alignment: Qt.AlignHCenter action: Kirigami.Action { text: i18nc("@action:button", "Synchronize") icon.name: "state-sync" onTriggered: { // not using pushPage here in order to open the sync page // directly pageStack.layers.clear() pageStack.pushDialogLayer("qrc:/qt/qml/org/kde/kasts/qml/Settings/SettingsPage.qml", { defaultPage: "Synchronization" }, { title: i18n("Settings"), }) } } } } property int minimumCardSize: 150 property int cardMargin: Kirigami.Units.largeSpacing // In order to account for the scrollbar popping up and creating a // binding loop, we calculate the number of columns and card width based // on the total width of the page itself rather than the width left for // the GridView, and then subtract some space property int availableWidth: subscriptionPage.width - !kastsMainWindow.isMobile * Kirigami.Units.gridUnit * 1.3 // TODO: get proper width for scrollbar rather than hardcoding it property int columns: Math.max(1, Math.floor(availableWidth / (minimumCardSize + 2 * cardMargin))) cellWidth: availableWidth / columns cellHeight: availableWidth / columns model: FeedsProxyModel { id: feedsModel sortType: kastsMainWindow.feedSorting } delegate: FeedListDelegate { cardSize: feedList.availableWidth / feedList.columns - 2 * feedList.cardMargin cardMargin: feedList.cardMargin listView: feedList } property var selectionForContextMenu: [] property ItemSelectionModel selectionModel: ItemSelectionModel { id: selectionModel model: feedList.model onSelectionChanged: { feedList.selectionForContextMenu = selectedIndexes; } } // The selection is not updated when the model is reset, so we have to take // this into account manually. // TODO: Fix the fact that the current item is not highlighted after reset Connections { target: feedList.model function onModelAboutToBeReset() { feedList.selectionForContextMenu = []; feedList.selectionModel.clear(); feedList.selectionModel.setCurrentIndex(feedList.model.index(0, 0), ItemSelectionModel.Current); // Only set current item; don't select it currentIndex = 0; } } Keys.onPressed: (event) => { if (event.matches(StandardKey.SelectAll)) { feedList.selectionModel.select(model.index(0, 0), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Columns); return; } switch (event.key) { case Qt.Key_Left: selectRelative(-1, event.modifiers == Qt.ShiftModifier); return; case Qt.Key_Right: selectRelative(1, event.modifiers == Qt.ShiftModifier); return; case Qt.Key_Up: selectRelative(-columns, event.modifiers == Qt.ShiftModifier); return; case Qt.Key_Down: selectRelative(columns, event.modifiers == Qt.ShiftModifier); return; case Qt.Key_PageUp: if (!atYBeginning) { if ((contentY - feedList.height) < 0) { contentY = 0 } else { contentY -= feedList.height } returnToBounds() } return; case Qt.Key_PageDown: if (!atYEnd) { if ((contentY + feedList.height) > contentHeight - height) { contentY = contentHeight - height } else { contentY += feedList.height } returnToBounds() } return; case Qt.Key_Home: if (!atYBeginning) { contentY = 0 returnToBounds() } return; case Qt.Key_End: if (!atYEnd) { contentY = contentHeight - height returnToBounds() } return; default: break; } } onActiveFocusChanged: { if (activeFocus && !selectionModel.hasSelection) { selectionModel.clear(); selectionModel.setCurrentIndex(model.index(0, 0), ItemSelectionModel.Current); // Only set current item; don't select it } } function selectRelative(delta, append) { var nextRow = feedList.currentIndex + delta; if (nextRow < 0) { nextRow = feedList.currentIndex; } if (nextRow >= feedList.count) { nextRow = feedList.currentIndex; } if (append) { feedList.selectionModel.select(feedList.model.createSelection(nextRow, feedList.selectionModel.currentIndex.row), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows); } else { feedList.selectionModel.setCurrentIndex(model.index(nextRow, 0), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows); } } // For lack of a better place, we put generic entry list actions here so // they can be re-used across the different ListViews. property var selectAllAction: Kirigami.Action { icon.name: "edit-select-all" text: i18nc("@action:intoolbar", "Select All") visible: true onTriggered: { feedList.selectionModel.select(feedList.model.index(0, 0), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Columns); } } property var selectNoneAction: Kirigami.Action { icon.name: "edit-select-none" text: i18nc("@action:intoolbar", "Deselect All") visible: feedList.selectionModel.hasSelection onTriggered: { feedList.selectionModel.clearSelection(); } } property var deleteFeedAction: Kirigami.Action { icon.name: "delete" text: i18ncp("@action:intoolbar", "Remove Podcast", "Remove Podcasts", feedList.selectionForContextMenu.length) visible: feedList.selectionModel.hasSelection onTriggered: { // First get an array of pointers to the feeds to be deleted // because the selected QModelIndexes will no longer be valid // after we start deleting feeds. var feeds = []; for (var i in feedList.selectionForContextMenu) { feeds[i] = feedList.model.data(feedList.selectionForContextMenu[i], FeedsModel.FeedRole); } for (var i in feeds) { if (lastFeed === feeds[i].url) { while (pageStack.depth > 1) { pageStack.pop(); } } } DataManager.removeFeeds(feeds); } } property var feedDetailsAction: Kirigami.Action { icon.name: "documentinfo" text: i18nc("@action:intoolbar Open view with more podcast details", "Podcast Details") visible: feedList.selectionModel.hasSelection && (feedList.selectionForContextMenu.length == 1) onTriggered: { while(pageStack.depth > 1) pageStack.pop(); pageStack.push("qrc:/qt/qml/org/kde/kasts/qml/FeedDetailsPage.qml", {"feed": feedList.selectionForContextMenu[0].model.data(feedList.selectionForContextMenu[0], FeedsModel.FeedRole)}); } } property var contextualActionList: [feedDetailsAction, deleteFeedAction, selectAllAction, selectNoneAction] property Controls.Menu contextMenu: Controls.Menu { id: contextMenu Controls.MenuItem { action: feedList.feedDetailsAction visible: (feedList.selectionForContextMenu.length == 1) height: visible ? implicitHeight : 0 // workaround for qqc2-breeze-style } Controls.MenuItem { action: feedList.deleteFeedAction visible: true height: visible ? implicitHeight : 0 // workaround for qqc2-breeze-style } onClosed: { // reset to normal selection if this context menu is closed feedList.selectionForContextMenu = feedList.selectionModel.selectedIndexes; } } } }